Skip to content

feat: optimistic caching for topic sub-feeds with per-URI health tracking - #805

Merged
Colin-XKL merged 6 commits into
devfrom
cursor/topic-subfeed-optimistic-cache-9d65
Jun 1, 2026
Merged

feat: optimistic caching for topic sub-feeds with per-URI health tracking#805
Colin-XKL merged 6 commits into
devfrom
cursor/topic-subfeed-optimistic-cache-9d65

Conversation

@Colin-XKL

@Colin-XKL Colin-XKL commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Overview

When a sub-feed inside a TopicFeed fails to fetch, the topic feed now automatically falls back to the last cached result (stored in Redis for up to 14 days), keeping the aggregated feed available even during partial upstream failures.

A new "Sub-Feed Cache Health" section in the admin topic detail page gives operators visibility into per-URI success/failure history, last successful update time, and recent error messages.

Changes

Backend

  • internal/constant/ttl.go: Add SubFeedCacheTTL (14 days) and SubFeedHealthTTL (30 days)
  • internal/constant/cache_namespace.go: Add PrefixSubFeedResult and PrefixSubFeedHealth Redis key prefixes
  • internal/feedruntime/cached_provider.go (new): CachedFeedProvider wraps any FeedProvider; on successful fetch caches the CraftFeed in Redis; on failure returns the last cached snapshot. Health metadata (last_success_at, last_failure_at, last_error, cached_at) is stored per URI in Redis.
  • internal/feedruntime/builder.go: Wrap every topic input with CachedFeedProvider during BuildTopic
  • internal/util/cache.go: Add TryCacheSetString / TryCacheGetString — safe variants that return errors instead of calling log.Fatalf when Redis is unconfigured (required for test environments)
  • internal/controller/topic_feed.go: Add sub_feed_health []SubFeedHealth to TopicDetailResponse; populated from Redis via feedruntime.GetSubFeedHealth

Frontend

  • web/admin/src/api/topic.ts: Add SubFeedHealth type; extend TopicDetail
  • web/admin/src/views/dashboard/topic_feed/detail.vue: New "Sub-Feed Cache Health" card in the topic detail page — shows per-URI status (green/orange/red), last success time, last failure time, last error text, and cache timestamp
  • web/admin/src/locale/zh-CN/topic.ts & en-US/topic.ts: i18n keys for the new section

Tests

  • internal/feedruntime/builder_test.go: Update TestBuildTopicProvider_NestedTopics to unwrap CachedFeedProvider before asserting inner type

Demo

topic_subfeed_health_demo.mp4

Topic detail page — sub-feed health with one OK feed and one failing feed:
Topic detail sub-feed health section

Topic with fully-failing feed served from cache:
Topic cache fallback detail

Behavior Notes

  • Optimistic strategy: The CachedFeedProvider is transparent — if live fetch fails but cache exists, the parent TopicFeed sees a successful result (no error propagation). The degraded state is visible only via the per-URI health section in admin.
  • Cache TTL: 14 days for feed content, 30 days for health metadata.
  • No DB changes: All health data lives in Redis. Naturally expires; no migration required.
  • Test safety: TryCacheSetString / TryCacheGetString never crash in environments without Redis — existing controller tests continue to pass.

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

Summary by Sourcery

Add optimistic caching and per-URI health tracking for topic sub-feeds and expose this metadata in the admin topic detail view.

New Features:

  • Introduce a CachedFeedProvider that wraps topic sub-feeds with Redis-backed optimistic caching and per-URI health metadata.
  • Expose sub-feed health data on the admin topic detail page, including status, last success/failure times, last error, and cache timestamp.

Enhancements:

  • Add Redis TTL constants and cache key namespaces for sub-feed results and health metadata.
  • Extend the topic detail API and frontend types to include sub-feed health information.
  • Introduce non-fatal Redis cache helpers that are safe to use in environments without Redis and update topic builder tests to account for the new caching wrapper.

Tests:

  • Adjust topic feed builder tests to unwrap the CachedFeedProvider when asserting nested topic inputs.

…tracking

When a sub-feed in a TopicFeed fails to fetch, the system now automatically
falls back to the last cached result (stored up to 14 days in Redis) so the
topic feed remains available despite partial upstream failures.

Changes:
- internal/constant/ttl.go: add SubFeedCacheTTL (14d) and SubFeedHealthTTL (30d)
- internal/constant/cache_namespace.go: add PrefixSubFeedResult and PrefixSubFeedHealth
- internal/feedruntime/cached_provider.go (new): CachedFeedProvider wraps any
  FeedProvider; on failure returns cached snapshot; health metadata (last success,
  last failure, last error, cached_at) persisted in Redis
- internal/feedruntime/builder.go: wrap each topic input with CachedFeedProvider
- internal/util/cache.go: add TryCacheSetString / TryCacheGetString that return
  errors instead of calling log.Fatalf when Redis is unavailable (test-safe)
- internal/controller/topic_feed.go: add sub_feed_health to TopicDetailResponse
- web/admin/src/api/topic.ts: add SubFeedHealth type; update TopicDetail
- web/admin/src/views/dashboard/topic_feed/detail.vue: add sub-feed health card
  showing per-URI last-success, last-failure, error message, and cache status
- web/admin/src/locale/zh-CN/topic.ts, en-US/topic.ts: add i18n keys
- internal/feedruntime/builder_test.go: update nested-topic test for CachedFeedProvider

Co-authored-by: Colin <Colin_XKL@outlook.com>
@vercel

vercel Bot commented Jun 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
feed-craft-admin Ready Ready Preview, Comment Jun 1, 2026 7:38am
feed-craft-doc Ready Ready Preview, Comment Jun 1, 2026 7:38am

@sourcery-ai

sourcery-ai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Implements an optimistic caching layer around topic sub-feed providers that stores per-URI health metadata in Redis, exposes this health in the topic detail API, and surfaces it in the admin UI as a new Sub-Feed Cache Health table, along with non-fatal Redis helpers and minimal test updates.

Sequence diagram for optimistic CachedFeedProvider.Fetch behavior

sequenceDiagram
    participant Client
    participant CachedFeedProvider
    participant InnerFeedProvider
    participant Redis

    Client->>CachedFeedProvider: Fetch(ctx)
    CachedFeedProvider->>InnerFeedProvider: Fetch(ctx)
    alt [live fetch succeeds]
        InnerFeedProvider-->>CachedFeedProvider: feed, nil
        CachedFeedProvider->>Redis: TryCacheSetString(subFeedResultKey, feedJSON, SubFeedCacheTTL)
        CachedFeedProvider->>Redis: TryCacheSetString(subFeedHealthKey, successHealthJSON, SubFeedHealthTTL)
        CachedFeedProvider-->>Client: feed, nil
    else [live fetch fails]
        InnerFeedProvider-->>CachedFeedProvider: nil, err
        CachedFeedProvider->>Redis: TryCacheGetString(subFeedResultKey)
        alt [cached snapshot exists]
            Redis-->>CachedFeedProvider: feedJSON
            CachedFeedProvider->>Redis: TryCacheSetString(subFeedHealthKey, failureHealthJSON, SubFeedHealthTTL)
            CachedFeedProvider-->>Client: cachedFeed, nil
        else [no cached snapshot]
            Redis-->>CachedFeedProvider: "", error
            CachedFeedProvider->>Redis: TryCacheSetString(subFeedHealthKey, failureHealthJSON, SubFeedHealthTTL)
            CachedFeedProvider-->>Client: nil, err
        end
    end
Loading

File-Level Changes

Change Details Files
Add optimistic cached wrapper around topic sub-feed providers with Redis-backed result and health tracking.
  • Introduce CachedFeedProvider that wraps any FeedProvider, caches successful CraftFeed results, and on fetch failures falls back to the last cached snapshot if available.
  • Store per-URI SubFeedHealth metadata (last success/failure timestamps, last error, cached-at time) in Redis using SHA-256–derived keys and dedicated prefixes.
  • Provide GetSubFeedHealth helper to read health metadata for a given sub-feed URI, returning a default struct when no data exists.
internal/feedruntime/cached_provider.go
Wire cached provider and health tracking into topic feed building and topic detail API.
  • Wrap each topic input provider with CachedFeedProvider during BuildTopic so all sub-feeds benefit from optimistic caching.
  • Extend TopicDetailResponse with a SubFeedHealth array and populate it in GetTopicFeedDetail by fetching health for each input URI from feedruntime.
  • Adjust nested topic builder test to unwrap CachedFeedProvider before asserting inner provider type.
internal/feedruntime/builder.go
internal/controller/topic_feed.go
internal/feedruntime/builder_test.go
Define Redis TTLs and key namespaces for sub-feed caching and health metadata.
  • Add SubFeedCacheTTL (14 days) and SubFeedHealthTTL (30 days) constants.
  • Add PrefixSubFeedResult and PrefixSubFeedHealth Redis key prefixes for sub-feed result snapshots and health records.
internal/constant/ttl.go
internal/constant/cache_namespace.go
Introduce non-fatal Redis cache helpers safe for test environments.
  • Add tryGetRedisClient which returns a configured Redis client or an error instead of exiting.
  • Implement TryCacheSetString and TryCacheGetString wrappers that use tryGetRedisClient and return errors when Redis is unavailable, used by the cached provider and health tracking.
internal/util/cache.go
Expose sub-feed health data in the admin UI topic detail view.
  • Extend TopicDetail API type with sub_feed_health and add a SubFeedHealth TypeScript interface matching the backend payload.
  • Add a Sub-Feed Cache Health card to the topic detail Vue component, rendering a table of per-URI status, timestamps, and last error, with color-coded status tags derived from last success/failure times.
  • Add English and Chinese i18n strings for the new Sub-Feed Cache Health section, labels, status texts, empty-state description, and explanatory hint, and minor styling for URI and error text display.
web/admin/src/api/topic.ts
web/admin/src/views/dashboard/topic_feed/detail.vue
web/admin/src/locale/en-US/topic.ts
web/admin/src/locale/zh-CN/topic.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces optimistic caching and health tracking for sub-feeds during topic aggregation, allowing failing sub-feeds to fall back to their last known good snapshot. It also adds a new 'Sub-Feed Cache Health' section to the admin dashboard. The review feedback highlights a critical connection leak in the newly added tryGetRedisClient function, recommending that the Redis client be cached thread-safely using a mutex. Additionally, the reviewer noted a UI logic issue where sub-feeds that have failed without any cached fallback are misleadingly labeled as 'Using Cache' instead of 'Failed', and provided suggestions to update the status labeling and translation keys.

Comment thread internal/util/cache.go
Comment on lines +6 to +8
"errors"
"strings"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Import the sync package to allow thread-safe caching of the Redis client in tryGetRedisClient.

Suggested change
"errors"
"strings"
"errors"
"strings"
"sync"

Comment thread internal/util/cache.go
Comment on lines +35 to +55
// tryGetRedisClient returns a Redis client or an error without fatally crashing.
// It is safe to call in environments where Redis may not be configured (e.g. tests).
func tryGetRedisClient() (*redis.Client, error) {
envClient := GetEnvClient()
if envClient == nil {
return nil, errors.New("env client not available")
}
redisURI := strings.TrimSpace(envClient.GetString("REDIS_URI"))
if redisURI == "" {
return nil, errors.New("REDIS_URI is not configured")
}
opts, err := redis.ParseURL(redisURI)
if err != nil {
return nil, err
}
rdb := redis.NewClient(opts)
if rdb == nil {
return nil, errors.New("failed to create redis client")
}
return rdb, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Creating a new Redis client and connection pool on every call to tryGetRedisClient will cause a severe connection leak, especially since TryCacheGetString is called in a loop for each sub-feed URI in GetTopicFeedDetail. Cache the client instance using a mutex to ensure thread safety and prevent resource exhaustion.

var (
	tryRdb     *redis.Client
	tryRdbMu   sync.Mutex
	tryRdbErr  error
	tryRdbInit bool
)

// tryGetRedisClient returns a Redis client or an error without fatally crashing.
// It is safe to call in environments where Redis may not be configured (e.g. tests).
func tryGetRedisClient() (*redis.Client, error) {
	tryRdbMu.Lock()
	defer tryRdbMu.Unlock()

	if tryRdbInit {
		return tryRdb, tryRdbErr
	}

	envClient := GetEnvClient()
	if envClient == nil {
		return nil, errors.New("env client not available")
	}
	redisURI := strings.TrimSpace(envClient.GetString("REDIS_URI"))
	if redisURI == "" {
		return nil, errors.New("REDIS_URI is not configured")
	}
	opts, err := redis.ParseURL(redisURI)
	if err != nil {
		tryRdbErr = err
		tryRdbInit = true
		return nil, err
	}
	tryRdb = redis.NewClient(opts)
	if tryRdb == nil {
		tryRdbErr = errors.New("failed to create redis client")
	}
	tryRdbInit = true
	return tryRdb, tryRdbErr
}

Comment on lines +110 to +113
'topic.detail.subFeedHealth.status': 'Status',
'topic.detail.subFeedHealth.status.ok': 'OK',
'topic.detail.subFeedHealth.status.stale': 'Using Cache',
'topic.detail.subFeedHealth.status.unknown': 'Unknown',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a translation key for the 'failed' status of a sub-feed when it has never succeeded and has no cached snapshot to fall back to.

  'topic.detail.subFeedHealth.status': 'Status',
  'topic.detail.subFeedHealth.status.ok': 'OK',
  'topic.detail.subFeedHealth.status.stale': 'Using Cache',
  'topic.detail.subFeedHealth.status.failed': 'Failed',
  'topic.detail.subFeedHealth.status.unknown': 'Unknown',

Comment on lines +107 to +110
'topic.detail.subFeedHealth.status': '状态',
'topic.detail.subFeedHealth.status.ok': '正常',
'topic.detail.subFeedHealth.status.stale': '使用缓存',
'topic.detail.subFeedHealth.status.unknown': '未知',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a translation key for the 'failed' status of a sub-feed in Chinese.

Suggested change
'topic.detail.subFeedHealth.status': '状态',
'topic.detail.subFeedHealth.status.ok': '正常',
'topic.detail.subFeedHealth.status.stale': '使用缓存',
'topic.detail.subFeedHealth.status.unknown': '未知',
'topic.detail.subFeedHealth.status': '状态',
'topic.detail.subFeedHealth.status.ok': '正常',
'topic.detail.subFeedHealth.status.stale': '使用缓存',
'topic.detail.subFeedHealth.status.failed': '失败',
'topic.detail.subFeedHealth.status.unknown': '未知',

Comment on lines +384 to +396
const subFeedStatusLabel = (record: SubFeedHealth) => {
if (!record.last_success_at && !record.last_failure_at)
return t('topic.detail.subFeedHealth.status.unknown');
if (record.last_failure_at && !record.last_success_at)
return t('topic.detail.subFeedHealth.status.stale');
if (record.last_failure_at && record.last_success_at) {
const failedAt = new Date(record.last_failure_at).getTime();
const succeededAt = new Date(record.last_success_at).getTime();
if (failedAt > succeededAt)
return t('topic.detail.subFeedHealth.status.stale');
}
return t('topic.detail.subFeedHealth.status.ok');
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When a sub-feed has failed but has never succeeded (record.last_failure_at && !record.last_success_at), it cannot fall back to any cached snapshot. Showing 'Using Cache' (stale) is misleading. Update the status label to return the 'failed' status instead.

  const subFeedStatusLabel = (record: SubFeedHealth) => {
    if (!record.last_success_at && !record.last_failure_at)
      return t('topic.detail.subFeedHealth.status.unknown');
    if (record.last_failure_at && !record.last_success_at)
      return t('topic.detail.subFeedHealth.status.failed');
    if (record.last_failure_at && record.last_success_at) {
      const failedAt = new Date(record.last_failure_at).getTime();
      const succeededAt = new Date(record.last_success_at).getTime();
      if (failedAt > succeededAt)
        return t('topic.detail.subFeedHealth.status.stale');
    }
    return t('topic.detail.subFeedHealth.status.ok');
  };

@codacy-production

codacy-production Bot commented Jun 1, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@Colin-XKL
Colin-XKL marked this pull request as ready for review June 1, 2026 07:21
@qodo-code-review

Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Add optimistic caching for topic sub-feeds with per-URI health tracking

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Implement optimistic caching for topic sub-feeds with automatic fallback to cached results when
  live fetch fails
• Add per-URI health tracking stored in Redis with success/failure timestamps and error messages
• Display sub-feed cache health status in admin topic detail page with color-coded status indicators
• Introduce test-safe Redis cache helpers that gracefully handle missing Redis configuration
Diagram
flowchart LR
  A["Topic Feed Request"] --> B["BuildTopic"]
  B --> C["Wrap Each Input<br/>with CachedFeedProvider"]
  C --> D["Live Fetch"]
  D -->|Success| E["Cache Result<br/>+ Record Success"]
  D -->|Failure| F["Load Cached<br/>Snapshot"]
  F -->|Cache Hit| G["Return Cached<br/>Feed"]
  F -->|Cache Miss| H["Propagate Error"]
  E --> I["Store in Redis<br/>14 days TTL"]
  G --> J["Topic Aggregation<br/>Continues"]
  E --> K["Update Health<br/>Metadata"]
  F --> K
  K --> L["Store in Redis<br/>30 days TTL"]
  L --> M["Admin UI Shows<br/>Sub-Feed Health"]

Loading

Grey Divider

File Changes

1. internal/constant/cache_namespace.go ⚙️ Configuration changes +6/-0

Add Redis key prefixes for sub-feed caching

internal/constant/cache_namespace.go


2. internal/constant/ttl.go ⚙️ Configuration changes +7/-0

Define TTL constants for sub-feed cache and health

internal/constant/ttl.go


3. internal/feedruntime/cached_provider.go ✨ Enhancement +150/-0

Implement CachedFeedProvider with optimistic fallback logic

internal/feedruntime/cached_provider.go


View more (8)
4. internal/feedruntime/builder.go ✨ Enhancement +3/-1

Wrap topic inputs with CachedFeedProvider during build

internal/feedruntime/builder.go


5. internal/util/cache.go ✨ Enhancement +45/-0

Add test-safe Redis cache helper functions

internal/util/cache.go


6. internal/controller/topic_feed.go ✨ Enhancement +17/-7

Add sub-feed health data to topic detail response

internal/controller/topic_feed.go


7. internal/feedruntime/builder_test.go 🧪 Tests +4/-1

Update test to unwrap CachedFeedProvider for assertions

internal/feedruntime/builder_test.go


8. web/admin/src/api/topic.ts ✨ Enhancement +9/-0

Add SubFeedHealth type to API models

web/admin/src/api/topic.ts


9. web/admin/src/views/dashboard/topic_feed/detail.vue ✨ Enhancement +129/-1

Display sub-feed health card with status table and indicators

web/admin/src/views/dashboard/topic_feed/detail.vue


10. web/admin/src/locale/en-US/topic.ts 📝 Documentation +14/-0

Add English i18n keys for sub-feed health section

web/admin/src/locale/en-US/topic.ts


11. web/admin/src/locale/zh-CN/topic.ts 📝 Documentation +13/-0

Add Chinese i18n keys for sub-feed health section

web/admin/src/locale/zh-CN/topic.ts


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (2)

Grey Divider


Action required

1. Cached fetch failure not propagated 📘 Rule violation ☼ Reliability
Description
CachedFeedProvider.Fetch returns cached data with a nil error when the live fetch fails,
preventing upstream callers from detecting degraded behavior. This violates the requirement to
propagate failures instead of swallowing errors.
Code

internal/feedruntime/cached_provider.go[R47-56]

Evidence
PR Compliance ID 4 requires propagating failures rather than converting them into success. The new
code explicitly returns a cached snapshot with nil error after a live fetch error, which masks the
failure from upstream handling.

internal/feedruntime/cached_provider.go[47-56]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CachedFeedProvider.Fetch` swallows the live fetch error when a cached snapshot exists by returning `(cached, nil)`. This prevents upstream layers (topic aggregation, observability, callers) from detecting and reacting to failures.

## Issue Context
Compliance requires errors from fallible operations to be propagated (or otherwise made explicitly detectable/actionable), not converted into success silently.

## Fix Focus Areas
- internal/feedruntime/cached_provider.go[47-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. cached_at set without cache 🐞 Bug ◔ Observability
Description
SubFeedHealth.CachedAt is documented as “when the last successful result was written to the cache”,
but recordSuccess sets it unconditionally even if the Redis write in cacheResult fails, making
health data claim a cached snapshot exists when it might not.
Code

internal/feedruntime/cached_provider.go[R85-94]

Evidence
The code sets CachedAt in recordSuccess regardless of whether TryCacheSetString succeeded; the
comment explicitly defines CachedAt as the time the result was written to cache.

internal/feedruntime/cached_provider.go[18-26]
internal/feedruntime/cached_provider.go[39-45]
internal/feedruntime/cached_provider.go[59-69]
internal/feedruntime/cached_provider.go[85-94]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`recordSuccess()` always sets `CachedAt = now` even when `cacheResult()` fails to write to Redis. This contradicts the struct comment and makes the admin “Cache Written” timestamp unreliable.

### Issue Context
`Fetch()` calls `cacheResult(feed)` and then `recordSuccess()` on any successful upstream fetch; `cacheResult()` only logs on Redis write failure and does not communicate success/failure to `recordSuccess()`.

### Fix Focus Areas
- internal/feedruntime/cached_provider.go[39-57]
- internal/feedruntime/cached_provider.go[59-69]
- internal/feedruntime/cached_provider.go[85-104]

### Implementation notes
- Make `cacheResult` return `bool` (or `error`) indicating whether the Redis write succeeded.
- Pass the outcome into `recordSuccess` and only set `CachedAt` when the write succeeded.
- Alternatively, rename the field/comment to reflect “last successful fetch attempt” if that’s the intended meaning (and update UI label accordingly).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. GetSubFeedHealth drops Redis errors 📘 Rule violation ◔ Observability
Description
GetSubFeedHealth discards Redis read errors by returning a zero-value health struct, which can
hide operational failures (e.g., Redis outages/misconfig). This violates the requirement to not
ignore IO errors without propagation or visibility.
Code

internal/feedruntime/cached_provider.go[R131-134]

Evidence
PR Compliance ID 4 prohibits discarding IO errors. The new GetSubFeedHealth treats (err != nil)
the same as "no data" and returns an empty struct without returning/logging the error, masking Redis
failures.

internal/feedruntime/cached_provider.go[131-134]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`GetSubFeedHealth` ignores the error returned by `util.TryCacheGetString(...)` and returns an empty `SubFeedHealth`, making Redis failures invisible to callers.

## Issue Context
Compliance requires IO errors to be propagated or at least surfaced in a way that makes failures detectable and actionable.

## Fix Focus Areas
- internal/feedruntime/cached_provider.go[131-134]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Redis client not reused 🐞 Bug ➹ Performance
Description
TryCacheGetString/TryCacheSetString create a new redis.Client for every cache operation; with
CachedFeedProvider invoked per topic input (often concurrently), this causes redundant connection
pools and avoidable overhead during topic fetches and admin detail loads.
Code

internal/util/cache.go[R35-75]

Evidence
The new TryCache* helpers instantiate a new Redis client per call, and CachedFeedProvider uses those
helpers on every success/failure path while TopicFeed fetches inputs concurrently, multiplying the
number of client instances created per topic fetch.

internal/util/cache.go[35-75]
internal/feedruntime/cached_provider.go[39-83]
internal/engine/topic.go[39-63]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`TryCacheGetString` / `TryCacheSetString` call `tryGetRedisClient()` which creates a new `redis.Client` each time. With the new `CachedFeedProvider` used for every topic input (and executed concurrently), this can create many independent Redis client instances/pools, increasing latency and resource usage.

### Issue Context
This PR introduces per-input caching + health writes/reads, making Redis usage far more frequent than before (topic aggregation fetches inputs concurrently).

### Fix Focus Areas
- internal/util/cache.go[35-75]
- internal/feedruntime/cached_provider.go[39-57]

### Implementation notes
- Introduce a package-level singleton (e.g., `sync.Once` + cached `*redis.Client`) for the safe path, similar to `GetRedisClient()` but returning `(client, ok/error)` instead of `log.Fatalf`.
- Ensure the singleton is only created when `REDIS_URI` is configured; otherwise return a stable error.
- Keep using pooled connections; do not create new clients per request/call.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. UI mislabels failed feeds 🐞 Bug ≡ Correctness
Description
The admin UI maps (last_failure_at && !last_success_at) to status “Using Cache”, but backend
CachedFeedProvider propagates errors when no cached snapshot exists, so this state can represent a
hard failure (no cache) rather than cache fallback.
Code

web/admin/src/views/dashboard/topic_feed/detail.vue[R384-395]

Evidence
The UI explicitly returns the i18n key for “Using Cache” when there is a failure but no success,
while the backend only returns cached data if it exists and otherwise returns an error; therefore
the UI state does not imply cache fallback is active.

web/admin/src/views/dashboard/topic_feed/detail.vue[371-396]
web/admin/src/locale/en-US/topic.ts[104-113]
internal/feedruntime/cached_provider.go[47-56]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
UI status “Using Cache” is shown when there is a failure timestamp but no success timestamp. However, backend only returns cached data when a cached snapshot exists; otherwise it returns an error. The UI currently cannot distinguish these cases and can mislead operators.

### Issue Context
- Backend returns cached snapshot only when Redis has one; otherwise returns the original error.
- UI uses only `last_success_at` / `last_failure_at` to decide “Using Cache”.

### Fix Focus Areas
- web/admin/src/views/dashboard/topic_feed/detail.vue[371-396]
- web/admin/src/locale/en-US/topic.ts[110-113]
- internal/feedruntime/cached_provider.go[47-56]

### Implementation notes
- Use `cached_at` (once made reliable) to decide “Using Cache”: e.g., show “Using Cache” only when `cached_at` exists and the latest failure is after the latest success.
- Add a separate “Failed (no cache)” status when `last_failure_at` exists but `cached_at`/`last_success_at` is missing.
- Align color/label mapping (currently red color but “Using Cache” label).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In the Vue subFeedStatusColor/subFeedStatusLabel helpers, the case last_failure_at && !last_success_at returns a red tag color but the label 'Using Cache', which is inconsistent with the backing data (no successful cache write yet); consider aligning this branch to represent a pure failure state (e.g. red + 'Error') or only use 'Using Cache' when a successful cache exists.
  • The new tryGetRedisClient creates a fresh Redis client (and reparses REDIS_URI) on every cache call, which is wasteful and may leak connections; consider reusing the existing client from GetRedisClient or memoizing a singleton *redis.Client for the TryCache helpers.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the Vue `subFeedStatusColor`/`subFeedStatusLabel` helpers, the case `last_failure_at && !last_success_at` returns a red tag color but the label 'Using Cache', which is inconsistent with the backing data (no successful cache write yet); consider aligning this branch to represent a pure failure state (e.g. red + 'Error') or only use 'Using Cache' when a successful cache exists.
- The new `tryGetRedisClient` creates a fresh Redis client (and reparses `REDIS_URI`) on every cache call, which is wasteful and may leak connections; consider reusing the existing client from `GetRedisClient` or memoizing a singleton `*redis.Client` for the TryCache helpers.

## Individual Comments

### Comment 1
<location path="web/admin/src/views/dashboard/topic_feed/detail.vue" line_range="373-375" />
<code_context>

+  // Returns a colour for the sub-feed status tag.
+  // "stale" (using cache) = orange; "ok" (last_failure is absent or last_success is more recent) = green; unknown = gray.
+  const subFeedStatusColor = (record: SubFeedHealth) => {
+    if (!record.last_success_at && !record.last_failure_at) return 'gray';
+    if (record.last_failure_at && !record.last_success_at) return 'red';
+    if (record.last_failure_at && record.last_success_at) {
+      const failedAt = new Date(record.last_failure_at).getTime();
</code_context>
<issue_to_address>
**issue:** Status color and label semantics are inconsistent for the `last_failure_at && !last_success_at` case.

When `last_failure_at` is set and `last_success_at` is not, `subFeedStatusColor` returns `'red'` but `subFeedStatusLabel` returns `'Using Cache'` (stale), and the function comment only mentions orange/green/gray. This breaks the color/label mapping and can confuse users. Please either use the same "stale" color (orange) for this branch as the `failedAt > succeededAt` case, or introduce a distinct label for this hard-error case so color and label semantics stay consistent.
</issue_to_address>

### Comment 2
<location path="internal/util/cache.go" line_range="37-46" />
<code_context>

+// tryGetRedisClient returns a Redis client or an error without fatally crashing.
+// It is safe to call in environments where Redis may not be configured (e.g. tests).
+func tryGetRedisClient() (*redis.Client, error) {
+	envClient := GetEnvClient()
+	if envClient == nil {
+		return nil, errors.New("env client not available")
+	}
+	redisURI := strings.TrimSpace(envClient.GetString("REDIS_URI"))
+	if redisURI == "" {
+		return nil, errors.New("REDIS_URI is not configured")
+	}
+	opts, err := redis.ParseURL(redisURI)
+	if err != nil {
+		return nil, err
+	}
+	rdb := redis.NewClient(opts)
+	if rdb == nil {
+		return nil, errors.New("failed to create redis client")
</code_context>
<issue_to_address>
**suggestion (performance):** A new Redis client is created for every TryCache call, which can add unnecessary overhead.

Both `TryCacheSetString` and `TryCacheGetString` call `tryGetRedisClient`, which parses `REDIS_URI` and constructs a new `redis.Client` each time. Since the client already manages its own pool, this repeated construction is unnecessary overhead and may complicate connection handling in long-lived services. Consider reusing a shared non-fatal client instance (similar to `GetRedisClient`, but returning errors instead of calling `log.Fatal`) while still failing gracefully when `REDIS_URI` is missing or invalid.

Suggested implementation:

```golang
	"github.com/redis/go-redis/v9"
	"github.com/sirupsen/logrus"
	"log"
	"sync"

```

```golang
	return rdb
}

var (
	tryRedisClient     *redis.Client
	tryRedisClientErr  error
	tryRedisClientOnce sync.Once
)

// tryGetRedisClient returns a Redis client or an error without fatally crashing.
// It is safe to call in environments where Redis may not be configured (e.g. tests).
// The client is constructed once and reused for subsequent calls.
func tryGetRedisClient() (*redis.Client, error) {
	tryRedisClientOnce.Do(func() {
		envClient := GetEnvClient()
		if envClient == nil {
			tryRedisClientErr = errors.New("env client not available")
			return
		}

		redisURI := strings.TrimSpace(envClient.GetString("REDIS_URI"))
		if redisURI == "" {
			tryRedisClientErr = errors.New("REDIS_URI is not configured")
			return
		}

		opts, err := redis.ParseURL(redisURI)
		if err != nil {
			tryRedisClientErr = err
			return
		}

		rdb := redis.NewClient(opts)
		if rdb == nil {
			tryRedisClientErr = errors.New("failed to create redis client")
			return
		}

		tryRedisClient = rdb
	})

	if tryRedisClientErr != nil {
		return nil, tryRedisClientErr
	}
	return tryRedisClient, nil
}

```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +373 to +375
const subFeedStatusColor = (record: SubFeedHealth) => {
if (!record.last_success_at && !record.last_failure_at) return 'gray';
if (record.last_failure_at && !record.last_success_at) return 'red';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Status color and label semantics are inconsistent for the last_failure_at && !last_success_at case.

When last_failure_at is set and last_success_at is not, subFeedStatusColor returns 'red' but subFeedStatusLabel returns 'Using Cache' (stale), and the function comment only mentions orange/green/gray. This breaks the color/label mapping and can confuse users. Please either use the same "stale" color (orange) for this branch as the failedAt > succeededAt case, or introduce a distinct label for this hard-error case so color and label semantics stay consistent.

Comment thread internal/util/cache.go
Comment on lines +37 to +46
func tryGetRedisClient() (*redis.Client, error) {
envClient := GetEnvClient()
if envClient == nil {
return nil, errors.New("env client not available")
}
redisURI := strings.TrimSpace(envClient.GetString("REDIS_URI"))
if redisURI == "" {
return nil, errors.New("REDIS_URI is not configured")
}
opts, err := redis.ParseURL(redisURI)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): A new Redis client is created for every TryCache call, which can add unnecessary overhead.

Both TryCacheSetString and TryCacheGetString call tryGetRedisClient, which parses REDIS_URI and constructs a new redis.Client each time. Since the client already manages its own pool, this repeated construction is unnecessary overhead and may complicate connection handling in long-lived services. Consider reusing a shared non-fatal client instance (similar to GetRedisClient, but returning errors instead of calling log.Fatal) while still failing gracefully when REDIS_URI is missing or invalid.

Suggested implementation:

	"github.com/redis/go-redis/v9"
	"github.com/sirupsen/logrus"
	"log"
	"sync"
	return rdb
}

var (
	tryRedisClient     *redis.Client
	tryRedisClientErr  error
	tryRedisClientOnce sync.Once
)

// tryGetRedisClient returns a Redis client or an error without fatally crashing.
// It is safe to call in environments where Redis may not be configured (e.g. tests).
// The client is constructed once and reused for subsequent calls.
func tryGetRedisClient() (*redis.Client, error) {
	tryRedisClientOnce.Do(func() {
		envClient := GetEnvClient()
		if envClient == nil {
			tryRedisClientErr = errors.New("env client not available")
			return
		}

		redisURI := strings.TrimSpace(envClient.GetString("REDIS_URI"))
		if redisURI == "" {
			tryRedisClientErr = errors.New("REDIS_URI is not configured")
			return
		}

		opts, err := redis.ParseURL(redisURI)
		if err != nil {
			tryRedisClientErr = err
			return
		}

		rdb := redis.NewClient(opts)
		if rdb == nil {
			tryRedisClientErr = errors.New("failed to create redis client")
			return
		}

		tryRedisClient = rdb
	})

	if tryRedisClientErr != nil {
		return nil, tryRedisClientErr
	}
	return tryRedisClient, nil
}

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cursor[bot], we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 23 minutes and 8 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 70d2b7d4-659e-4762-83cb-979e1504c7b4

📥 Commits

Reviewing files that changed from the base of the PR and between 6d28c35 and cd92a66.

📒 Files selected for processing (6)
  • AGENTS.md
  • internal/adapter/embedding.go
  • internal/feedruntime/cached_provider.go
  • internal/util/hash.go
  • internal/util/hash_test.go
  • web/admin/src/locale/zh-CN/topic.ts

Walkthrough

This PR adds optimistic Redis caching for sub-feed topic aggregation with persistent health tracking. When a sub-feed fails to fetch, the system returns the most recent cached snapshot; on success, the fresh result is cached. Health metadata recording timestamps and errors is persisted and exposed through a new "Sub-Feed Health" dashboard card in the topic feed detail view.

Changes

Sub-feed Caching and Health Display

Layer / File(s) Summary
Cache infrastructure: constants, TTL, and Redis helpers
internal/constant/cache_namespace.go, internal/constant/ttl.go, internal/util/cache.go
Adds cache namespace prefixes (PrefixSubFeedResult, PrefixSubFeedHealth) and TTL constants (14 days for feed snapshots, 30 days for health records). Introduces non-crashing Redis helpers (TryCacheSetString, TryCacheGetString, tryGetRedisClient) that return errors to callers instead of terminating fatally.
CachedFeedProvider: optimistic caching with health tracking
internal/feedruntime/cached_provider.go
Implements CachedFeedProvider wrapper that attempts live fetch, caches successes in Redis, records health metadata (timestamps and errors), and on failure returns cached snapshots if available. Defines SubFeedHealth struct and persistence helpers for storing/retrieving per-URI health data deterministically keyed by URI hash.
Backend integration: builder wrapping and API response
internal/feedruntime/builder.go, internal/feedruntime/builder_test.go, internal/controller/topic_feed.go
Wraps each input provider built in BuildTopic with CachedFeedProvider. Updates test to verify the wrapper is present. Extends TopicDetailResponse with SubFeedHealth field and populates it via GetSubFeedHealth(uri) for each input URI in GetTopicFeedDetail.
Frontend API contracts for sub-feed health
web/admin/src/api/topic.ts
Adds SubFeedHealth interface describing URI, last success/failure timestamps, last error text, and cache write time. Extends TopicDetail to include sub_feed_health array.
Frontend UI: dashboard display, helpers, and localization
web/admin/src/locale/en-US/topic.ts, web/admin/src/locale/zh-CN/topic.ts, web/admin/src/views/dashboard/topic_feed/detail.vue
Renders new "Sub-Feed Health" card with table showing status tag, URI, timestamps, and error text for each sub-feed. Adds subFeedStatusColor and subFeedStatusLabel helpers computing status (OK/stale/unknown) based on success/failure timing. Includes English and Chinese locale strings for section labels, status variants, empty state, and explanatory hint about 14-day cache fallback.

A rabbit whispers of caches wise and true,
When feeds stumble, old snapshots rescue the brew.
Fourteen days of memories, health tracked with care,
Sub-source health glows bright on the dashboard there. 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and specifically describes the main feature: optimistic caching for topic sub-feeds with per-URI health tracking, which is the core objective of the changeset.
Description check ✅ Passed The description comprehensively explains the feature, lists all modified files with their purposes, describes backend and frontend changes, and includes behavior notes—clearly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/topic-subfeed-optimistic-cache-9d65

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
internal/feedruntime/cached_provider.go (1)

86-104: 💤 Low value

CachedAt reflects the in-memory write time, not a confirmed cache persist.

recordSuccess sets CachedAt = &now regardless of whether cacheResult actually persisted to Redis (the write failure is only logged at warn). The health view will then show a cached_at even when nothing was cached. Acceptable if intentional, but worth confirming the dashboard semantics — surfacing cached_at only when the snapshot write succeeded would be more accurate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/feedruntime/cached_provider.go` around lines 86 - 104, recordSuccess
currently sets h.CachedAt unconditionally causing the health view to show a
cached time even when cacheResult failed; change the flow so CachedAt is only
set when the snapshot persist actually succeeds: either have cacheResult return
a success bool/error and set h.CachedAt after a successful cacheResult call, or
add a parameter to recordSuccess (e.g., persisted bool or err) and only assign
h.CachedAt = &now when persistence succeeded; keep LastSuccessAt updated
regardless and leave recordFailure unchanged, and ensure calls to saveHealth and
loadHealth still use the same functions (CachedFeedProvider.recordSuccess,
cacheResult, loadHealth, saveHealth).
internal/controller/topic_feed.go (1)

214-217: ⚖️ Poor tradeoff

Per-URI sequential Redis reads on the detail request path.

This loop issues one GetSubFeedHealth (a Redis GET) per input URI, sequentially, on the request thread — and each call also spins up a new Redis client (see the root-cause note in internal/util/cache.go). For topics with many inputs this is N round trips per detail load. If this view is hit frequently, consider batching the health lookups into a single pipelined/MGET call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/topic_feed.go` around lines 214 - 217, The current loop
in topic_feed.go calling feedruntime.GetSubFeedHealth for each uri causes N
sequential Redis GETs (and per-call client creation); replace this with a
batched lookup: add a new feedruntime function (e.g.,
GetSubFeedHealthBatch(inputURIs []string) []feedruntime.SubFeedHealth) that uses
a single MGET or pipelined requests via the shared Redis client (see
internal/util/cache.go) to fetch all entries in one round trip and returns the
slice in the same order, then replace the per-URI loop (topicData.InputURIs ->
subFeedHealth) with a single call to that new batch function.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/feedruntime/cached_provider.go`:
- Around line 60-69: The cacheResult function currently writes any successful
result (including nil or empty feeds) and thus can overwrite a good snapshot;
modify CachedFeedProvider.cacheResult to skip caching when the incoming feed is
nil or contains no articles (e.g., if feed == nil || len(feed.Articles) == 0)
before calling json.Marshal and TryCacheSetString (keep using
subFeedResultKey(p.URI) and util.TryCacheSetString); optionally emit a debug/log
message when skipping the cache write so the behavior is observable.

In `@internal/util/cache.go`:
- Around line 37-75: The code creates a new redis.Client on every call in
tryGetRedisClient (used by TryCacheSetString/TryCacheGetString and
CacheSetString/CacheGetString) and never closes them, causing many idle pools;
change to a package-level singleton redis client initialized once (use sync.Once
or similar) and return that from tryGetRedisClient/GetRedisClient instead of
calling redis.NewClient each time, ensure the existing Close call in
internal/controller/dependency_monitor.go (or add a single Close hook during app
shutdown) closes the singleton client, and remove per-call NewClient usage so
all cache ops reuse the same long-lived *redis.Client.

In `@web/admin/src/views/dashboard/topic_feed/detail.vue`:
- Around line 371-396: Both helpers subFeedStatusColor and subFeedStatusLabel
compute status logic separately and disagree for the case where last_failure_at
exists but last_success_at is absent; centralize by creating a single status
computation (e.g., computeSubFeedStatus(record: SubFeedHealth) returning an
explicit enum/value like 'unknown'|'failed'|'stale'|'ok') and then have
subFeedStatusColor and subFeedStatusLabel map that single status to color and to
i18n keys respectively; ensure the failure-only case maps to a new i18n key
(topic.detail.subFeedHealth.status.failed) and remove the duplicated timestamp
comparisons from both functions so they use the shared computeSubFeedStatus
function.

---

Nitpick comments:
In `@internal/controller/topic_feed.go`:
- Around line 214-217: The current loop in topic_feed.go calling
feedruntime.GetSubFeedHealth for each uri causes N sequential Redis GETs (and
per-call client creation); replace this with a batched lookup: add a new
feedruntime function (e.g., GetSubFeedHealthBatch(inputURIs []string)
[]feedruntime.SubFeedHealth) that uses a single MGET or pipelined requests via
the shared Redis client (see internal/util/cache.go) to fetch all entries in one
round trip and returns the slice in the same order, then replace the per-URI
loop (topicData.InputURIs -> subFeedHealth) with a single call to that new batch
function.

In `@internal/feedruntime/cached_provider.go`:
- Around line 86-104: recordSuccess currently sets h.CachedAt unconditionally
causing the health view to show a cached time even when cacheResult failed;
change the flow so CachedAt is only set when the snapshot persist actually
succeeds: either have cacheResult return a success bool/error and set h.CachedAt
after a successful cacheResult call, or add a parameter to recordSuccess (e.g.,
persisted bool or err) and only assign h.CachedAt = &now when persistence
succeeded; keep LastSuccessAt updated regardless and leave recordFailure
unchanged, and ensure calls to saveHealth and loadHealth still use the same
functions (CachedFeedProvider.recordSuccess, cacheResult, loadHealth,
saveHealth).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 516feb18-d428-4533-acf1-5e7d50c5d6f3

📥 Commits

Reviewing files that changed from the base of the PR and between 06db2ba and 6d28c35.

📒 Files selected for processing (11)
  • internal/constant/cache_namespace.go
  • internal/constant/ttl.go
  • internal/controller/topic_feed.go
  • internal/feedruntime/builder.go
  • internal/feedruntime/builder_test.go
  • internal/feedruntime/cached_provider.go
  • internal/util/cache.go
  • web/admin/src/api/topic.ts
  • web/admin/src/locale/en-US/topic.ts
  • web/admin/src/locale/zh-CN/topic.ts
  • web/admin/src/views/dashboard/topic_feed/detail.vue

Comment on lines +60 to +69
func (p *CachedFeedProvider) cacheResult(feed *model.CraftFeed) {
data, err := json.Marshal(feed)
if err != nil {
logrus.Warnf("CachedFeedProvider [%s]: marshal failed: %v", p.URI, err)
return
}
if err := util.TryCacheSetString(subFeedResultKey(p.URI), string(data), constant.SubFeedCacheTTL); err != nil {
logrus.Warnf("CachedFeedProvider [%s]: cache write failed: %v", p.URI, err)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid overwriting a good snapshot with an empty/degraded successful fetch.

cacheResult caches any non-error result, including a nil feed (which marshals to "null") or a successful response that transiently contains zero articles. When that happens the previous known-good snapshot is replaced, so a later failure falls back to an empty feed — undermining the purpose of the optimistic cache. Consider skipping the write when the feed is nil or has no articles.

🛡️ Proposed guard
 // cacheResult writes feed to Redis with SubFeedCacheTTL.
 func (p *CachedFeedProvider) cacheResult(feed *model.CraftFeed) {
+	// Don't overwrite a known-good snapshot with an empty/degraded result.
+	if feed == nil || len(feed.Articles) == 0 {
+		return
+	}
 	data, err := json.Marshal(feed)
 	if err != nil {
 		logrus.Warnf("CachedFeedProvider [%s]: marshal failed: %v", p.URI, err)
 		return
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (p *CachedFeedProvider) cacheResult(feed *model.CraftFeed) {
data, err := json.Marshal(feed)
if err != nil {
logrus.Warnf("CachedFeedProvider [%s]: marshal failed: %v", p.URI, err)
return
}
if err := util.TryCacheSetString(subFeedResultKey(p.URI), string(data), constant.SubFeedCacheTTL); err != nil {
logrus.Warnf("CachedFeedProvider [%s]: cache write failed: %v", p.URI, err)
}
}
func (p *CachedFeedProvider) cacheResult(feed *model.CraftFeed) {
// Don't overwrite a known-good snapshot with an empty/degraded result.
if feed == nil || len(feed.Articles) == 0 {
return
}
data, err := json.Marshal(feed)
if err != nil {
logrus.Warnf("CachedFeedProvider [%s]: marshal failed: %v", p.URI, err)
return
}
if err := util.TryCacheSetString(subFeedResultKey(p.URI), string(data), constant.SubFeedCacheTTL); err != nil {
logrus.Warnf("CachedFeedProvider [%s]: cache write failed: %v", p.URI, err)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/feedruntime/cached_provider.go` around lines 60 - 69, The
cacheResult function currently writes any successful result (including nil or
empty feeds) and thus can overwrite a good snapshot; modify
CachedFeedProvider.cacheResult to skip caching when the incoming feed is nil or
contains no articles (e.g., if feed == nil || len(feed.Articles) == 0) before
calling json.Marshal and TryCacheSetString (keep using subFeedResultKey(p.URI)
and util.TryCacheSetString); optionally emit a debug/log message when skipping
the cache write so the behavior is observable.

Comment thread internal/util/cache.go
Comment on lines +37 to +75
func tryGetRedisClient() (*redis.Client, error) {
envClient := GetEnvClient()
if envClient == nil {
return nil, errors.New("env client not available")
}
redisURI := strings.TrimSpace(envClient.GetString("REDIS_URI"))
if redisURI == "" {
return nil, errors.New("REDIS_URI is not configured")
}
opts, err := redis.ParseURL(redisURI)
if err != nil {
return nil, err
}
rdb := redis.NewClient(opts)
if rdb == nil {
return nil, errors.New("failed to create redis client")
}
return rdb, nil
}

// TryCacheSetString is like CacheSetString but returns an error instead of crashing
// when Redis is not configured. Safe to call in environments without Redis.
func TryCacheSetString(key string, value string, ttl time.Duration) error {
rdb, err := tryGetRedisClient()
if err != nil {
return err
}
return rdb.Set(context.Background(), key, value, ttl).Err()
}

// TryCacheGetString is like CacheGetString but returns an error instead of crashing
// when Redis is not configured. Safe to call in environments without Redis.
func TryCacheGetString(key string) (string, error) {
rdb, err := tryGetRedisClient()
if err != nil {
return "", err
}
return rdb.Get(context.Background(), key).Result()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚖️ Poor tradeoff

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Any Close() on redis clients?
rg -nP '\.Close\(\)' --type=go -C2
# Existing singleton/sync.Once redis client patterns?
rg -nP 'redis\.NewClient|sync\.Once|var\s+\w*[Rr]edis\w*\s' --type=go -C2

Repository: Colin-XKL/FeedCraft

Length of output: 10121


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the current contents of internal/util/cache.go around the relevant functions
sed -n '1,140p' internal/util/cache.go

# Find all usages of the helper functions in the repo
rg -n "tryGetRedisClient|TryCacheSetString|TryCacheGetString" --type=go internal || true

# Check whether any caller closes the client returned by tryGetRedisClient
rg -n "tryGetRedisClient\(\)" -S --type=go internal || true
rg -n "rdb\.(Close|Dispose|Shutdown|Stop)\(" --type=go internal || true

Repository: Colin-XKL/FeedCraft

Length of output: 4886


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect Redis client close/defer sites in dependency_monitor.go
sed -n '70,130p' internal/controller/dependency_monitor.go

# 2) Confirm no other callers of tryGetRedisClient (besides TryCache*)
rg -n "tryGetRedisClient" --type=go internal

# 3) Find any .Close() calls on redis clients (best-effort: look for Close() near rdb / redis.NewClient)
rg -n "rdb\.Close\(\)|redis\.NewClient|\.Close\(\)" --type=go internal/controller/dependency_monitor.go internal/util/cache.go

Repository: Colin-XKL/FeedCraft

Length of output: 3226


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any redis client close usage project-wide
rg -n --type=go "\.Close\(\)" internal | rg -n --fixed-string "redis" || true
rg -n --type=go "Close\(\)\s*$" internal || true

# Confirm whether tryGetRedisClient callers ever close returned client
rg -n --type=go "tryGetRedisClient\(\)" internal || true
rg -n --type=go "TryCache(Set|Get)String|CachedFunc" internal/feedruntime internal || true

# Check whether any other redis.NewClient usage exists besides dependency_monitor.go and util/cache.go
rg -n --type=go "redis\.NewClient\(" internal || true

Repository: Colin-XKL/FeedCraft

Length of output: 5067


Avoid creating a new Redis client/connection pool for every cache call

internal/util/cache.go’s tryGetRedisClient (and GetRedisClient via CacheSetString/CacheGetString) calls redis.NewClient(opts) on every invocation, while TryCacheSetString/TryCacheGetString never Close() the returned client; only internal/controller/dependency_monitor.go closes its own redis.NewClient. Reuse a single long-lived *redis.Client (e.g., sync.Once) across cache ops and close it during app shutdown to prevent accumulating idle pools.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/util/cache.go` around lines 37 - 75, The code creates a new
redis.Client on every call in tryGetRedisClient (used by
TryCacheSetString/TryCacheGetString and CacheSetString/CacheGetString) and never
closes them, causing many idle pools; change to a package-level singleton redis
client initialized once (use sync.Once or similar) and return that from
tryGetRedisClient/GetRedisClient instead of calling redis.NewClient each time,
ensure the existing Close call in internal/controller/dependency_monitor.go (or
add a single Close hook during app shutdown) closes the singleton client, and
remove per-call NewClient usage so all cache ops reuse the same long-lived
*redis.Client.

Comment on lines +371 to +396
// Returns a colour for the sub-feed status tag.
// "stale" (using cache) = orange; "ok" (last_failure is absent or last_success is more recent) = green; unknown = gray.
const subFeedStatusColor = (record: SubFeedHealth) => {
if (!record.last_success_at && !record.last_failure_at) return 'gray';
if (record.last_failure_at && !record.last_success_at) return 'red';
if (record.last_failure_at && record.last_success_at) {
const failedAt = new Date(record.last_failure_at).getTime();
const succeededAt = new Date(record.last_success_at).getTime();
if (failedAt > succeededAt) return 'orange';
}
return 'green';
};

const subFeedStatusLabel = (record: SubFeedHealth) => {
if (!record.last_success_at && !record.last_failure_at)
return t('topic.detail.subFeedHealth.status.unknown');
if (record.last_failure_at && !record.last_success_at)
return t('topic.detail.subFeedHealth.status.stale');
if (record.last_failure_at && record.last_success_at) {
const failedAt = new Date(record.last_failure_at).getTime();
const succeededAt = new Date(record.last_success_at).getTime();
if (failedAt > succeededAt)
return t('topic.detail.subFeedHealth.status.stale');
}
return t('topic.detail.subFeedHealth.status.ok');
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Color and label disagree for the failure-only state.

When last_failure_at is set but last_success_at is absent, subFeedStatusColor returns red while subFeedStatusLabel returns status.stale ("Using Cache"). A red tag labeled "Using Cache" is contradictory — with no prior success there is no cached snapshot to fall back to. The two helpers also duplicate the timestamp comparison, which is what allows them to drift.

Consider computing the status once and deriving both color and label from it, and giving the failure-only case its own label (e.g. a status.failed key) instead of reusing stale.

♻️ Single source of truth for status
-  // Returns a colour for the sub-feed status tag.
-  // "stale" (using cache) = orange; "ok" (last_failure is absent or last_success is more recent) = green; unknown = gray.
-  const subFeedStatusColor = (record: SubFeedHealth) => {
-    if (!record.last_success_at && !record.last_failure_at) return 'gray';
-    if (record.last_failure_at && !record.last_success_at) return 'red';
-    if (record.last_failure_at && record.last_success_at) {
-      const failedAt = new Date(record.last_failure_at).getTime();
-      const succeededAt = new Date(record.last_success_at).getTime();
-      if (failedAt > succeededAt) return 'orange';
-    }
-    return 'green';
-  };
-
-  const subFeedStatusLabel = (record: SubFeedHealth) => {
-    if (!record.last_success_at && !record.last_failure_at)
-      return t('topic.detail.subFeedHealth.status.unknown');
-    if (record.last_failure_at && !record.last_success_at)
-      return t('topic.detail.subFeedHealth.status.stale');
-    if (record.last_failure_at && record.last_success_at) {
-      const failedAt = new Date(record.last_failure_at).getTime();
-      const succeededAt = new Date(record.last_success_at).getTime();
-      if (failedAt > succeededAt)
-        return t('topic.detail.subFeedHealth.status.stale');
-    }
-    return t('topic.detail.subFeedHealth.status.ok');
-  };
+  type SubFeedStatus = 'unknown' | 'failed' | 'stale' | 'ok';
+
+  const subFeedStatus = (record: SubFeedHealth): SubFeedStatus => {
+    if (!record.last_success_at && !record.last_failure_at) return 'unknown';
+    if (record.last_failure_at && !record.last_success_at) return 'failed';
+    if (record.last_failure_at && record.last_success_at) {
+      const failedAt = new Date(record.last_failure_at).getTime();
+      const succeededAt = new Date(record.last_success_at).getTime();
+      if (failedAt > succeededAt) return 'stale';
+    }
+    return 'ok';
+  };
+
+  const statusColorMap: Record<SubFeedStatus, string> = {
+    unknown: 'gray',
+    failed: 'red',
+    stale: 'orange',
+    ok: 'green',
+  };
+
+  const subFeedStatusColor = (record: SubFeedHealth) =>
+    statusColorMap[subFeedStatus(record)];
+
+  const subFeedStatusLabel = (record: SubFeedHealth) =>
+    t(`topic.detail.subFeedHealth.status.${subFeedStatus(record)}`);

This assumes adding a topic.detail.subFeedHealth.status.failed key to both locale files (e.g. "Failed" / "失败").

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Returns a colour for the sub-feed status tag.
// "stale" (using cache) = orange; "ok" (last_failure is absent or last_success is more recent) = green; unknown = gray.
const subFeedStatusColor = (record: SubFeedHealth) => {
if (!record.last_success_at && !record.last_failure_at) return 'gray';
if (record.last_failure_at && !record.last_success_at) return 'red';
if (record.last_failure_at && record.last_success_at) {
const failedAt = new Date(record.last_failure_at).getTime();
const succeededAt = new Date(record.last_success_at).getTime();
if (failedAt > succeededAt) return 'orange';
}
return 'green';
};
const subFeedStatusLabel = (record: SubFeedHealth) => {
if (!record.last_success_at && !record.last_failure_at)
return t('topic.detail.subFeedHealth.status.unknown');
if (record.last_failure_at && !record.last_success_at)
return t('topic.detail.subFeedHealth.status.stale');
if (record.last_failure_at && record.last_success_at) {
const failedAt = new Date(record.last_failure_at).getTime();
const succeededAt = new Date(record.last_success_at).getTime();
if (failedAt > succeededAt)
return t('topic.detail.subFeedHealth.status.stale');
}
return t('topic.detail.subFeedHealth.status.ok');
};
type SubFeedStatus = 'unknown' | 'failed' | 'stale' | 'ok';
const subFeedStatus = (record: SubFeedHealth): SubFeedStatus => {
if (!record.last_success_at && !record.last_failure_at) return 'unknown';
if (record.last_failure_at && !record.last_success_at) return 'failed';
if (record.last_failure_at && record.last_success_at) {
const failedAt = new Date(record.last_failure_at).getTime();
const succeededAt = new Date(record.last_success_at).getTime();
if (failedAt > succeededAt) return 'stale';
}
return 'ok';
};
const statusColorMap: Record<SubFeedStatus, string> = {
unknown: 'gray',
failed: 'red',
stale: 'orange',
ok: 'green',
};
const subFeedStatusColor = (record: SubFeedHealth) =>
statusColorMap[subFeedStatus(record)];
const subFeedStatusLabel = (record: SubFeedHealth) =>
t(`topic.detail.subFeedHealth.status.${subFeedStatus(record)}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/admin/src/views/dashboard/topic_feed/detail.vue` around lines 371 - 396,
Both helpers subFeedStatusColor and subFeedStatusLabel compute status logic
separately and disagree for the case where last_failure_at exists but
last_success_at is absent; centralize by creating a single status computation
(e.g., computeSubFeedStatus(record: SubFeedHealth) returning an explicit
enum/value like 'unknown'|'failed'|'stale'|'ok') and then have
subFeedStatusColor and subFeedStatusLabel map that single status to color and to
i18n keys respectively; ensure the failure-only case maps to a new i18n key
(topic.detail.subFeedHealth.status.failed) and remove the duplicated timestamp
comparisons from both functions so they use the shared computeSubFeedStatus
function.

Reuse the existing util.GetMD5Hash helper instead of importing crypto/sha256
directly. MD5 is sufficient for cache key derivation.

Co-authored-by: Colin <Colin_XKL@outlook.com>
Comment on lines +47 to +56
// Live fetch failed — try the cached snapshot.
cached := p.loadCachedResult()
p.recordFailure(err)
if cached != nil {
logrus.Infof("CachedFeedProvider [%s]: live fetch failed (%v), returning cached snapshot", p.URI, err)
return cached, nil
}

// No cache available; propagate the original error.
return nil, err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Cached fetch failure not propagated 📘 Rule violation ☼ Reliability

CachedFeedProvider.Fetch returns cached data with a nil error when the live fetch fails,
preventing upstream callers from detecting degraded behavior. This violates the requirement to
propagate failures instead of swallowing errors.
Agent Prompt
## Issue description
`CachedFeedProvider.Fetch` swallows the live fetch error when a cached snapshot exists by returning `(cached, nil)`. This prevents upstream layers (topic aggregation, observability, callers) from detecting and reacting to failures.

## Issue Context
Compliance requires errors from fallible operations to be propagated (or otherwise made explicitly detectable/actionable), not converted into success silently.

## Fix Focus Areas
- internal/feedruntime/cached_provider.go[47-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +85 to +94
// recordSuccess updates the health metadata after a live successful fetch.
func (p *CachedFeedProvider) recordSuccess() {
now := time.Now()
h := p.loadHealth()
h.URI = p.URI
h.LastSuccessAt = &now
h.CachedAt = &now
h.LastError = ""
p.saveHealth(h)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Cached_at set without cache 🐞 Bug ◔ Observability

SubFeedHealth.CachedAt is documented as “when the last successful result was written to the cache”,
but recordSuccess sets it unconditionally even if the Redis write in cacheResult fails, making
health data claim a cached snapshot exists when it might not.
Agent Prompt
### Issue description
`recordSuccess()` always sets `CachedAt = now` even when `cacheResult()` fails to write to Redis. This contradicts the struct comment and makes the admin “Cache Written” timestamp unreliable.

### Issue Context
`Fetch()` calls `cacheResult(feed)` and then `recordSuccess()` on any successful upstream fetch; `cacheResult()` only logs on Redis write failure and does not communicate success/failure to `recordSuccess()`.

### Fix Focus Areas
- internal/feedruntime/cached_provider.go[39-57]
- internal/feedruntime/cached_provider.go[59-69]
- internal/feedruntime/cached_provider.go[85-104]

### Implementation notes
- Make `cacheResult` return `bool` (or `error`) indicating whether the Redis write succeeded.
- Pass the outcome into `recordSuccess` and only set `CachedAt` when the write succeeded.
- Alternatively, rename the field/comment to reflect “last successful fetch attempt” if that’s the intended meaning (and update UI label accordingly).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Co-authored-by: Colin <Colin_XKL@outlook.com>
cursoragent and others added 3 commits June 1, 2026 07:34
…ntHash

- GetMD5Hash renamed to getMD5Hash (package-private)
- GetPasswordMD5Hash is now the only exported MD5 entry point (security-focused)
- adapter/embedding.go and feedruntime/cached_provider.go switched to
  GetTextContentHash (FNV-64a, efficiency-focused) for cache key derivation
- hash_test.go updated to test via GetPasswordMD5Hash

Co-authored-by: Colin <Colin_XKL@outlook.com>
Co-authored-by: Colin <Colin_XKL@outlook.com>
Co-authored-by: Colin <Colin_XKL@outlook.com>
@Colin-XKL
Colin-XKL merged commit a930c53 into dev Jun 1, 2026
6 of 9 checks passed
@Colin-XKL
Colin-XKL deleted the cursor/topic-subfeed-optimistic-cache-9d65 branch June 1, 2026 07:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants