Skip to content

fix(frontend/recommendations): stop runaway refresh loop hammering the collector - #1123

Merged
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/recommendations-refresh-loop
Jun 9, 2026
Merged

cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/recommendations-refresh-loop

Conversation

@cristim

@cristim cristim commented Jun 9, 2026

Copy link
Copy Markdown
Member

Summary

Stops a runaway auto-refresh loop on the Recommendations (Opportunities) and Home (Dashboard) pages that fires the backend recommendation collector continuously. This is a cost risk: each collector run is a paid Cost Explorer / pricing API call (~$0.01) once it reaches AWS.

Repro

  1. Open the Recommendations page (or Home) while the recommendations cache is stale (older than 24h, or never collected).
  2. triggerAutoRefreshIfStale sees stale -> startRecommendationsRefresh -> POST /api/recommendations/refresh (the collector).
  3. The refresh POST returns success (202), so the .then path calls onReload() (loadRecommendations on Opportunities, loadDashboard on Home).
  4. onReload re-runs triggerAutoRefreshIfStale. Because the collector never advances last_collected_at (currently it fails fast at a DB error, so freshness stays stale forever), the page is still stale -> fire the collect again -> reload -> stale -> ... with no terminal condition.

Evidence

Production logs show the backend collector being hit ~4-10 times per second, sustained (~18k calls/hour), accompanied by a repeating "Recommendations refreshed" toast. Today those calls fail fast at a DB error before reaching Cost Explorer, but a DB fix is imminent: once it lands, every loop iteration becomes a continuous paid Cost Explorer / pricing call.

Root cause

frontend/src/recommendations.ts:

  • triggerAutoRefreshIfStale (the stale-on-open auto-refresh) has no once-per-session guard.
  • startRecommendationsRefresh's success path unconditionally calls onReload(), which re-enters triggerAutoRefreshIfStale.
  • The existing autoRefreshInFlight guard only dedups concurrent refreshes and is cleared in .finally() each cycle, so it cannot stop this sequential re-trigger.

This is a reactive/effect loop: the refresh's success reload re-evaluates the stale condition that triggered it, and the condition never clears.

Fix

Add a once-per-page-session latch (autoRefreshAttempted) checked at the top of triggerAutoRefreshIfStale, before the freshness GET. The stale-on-open auto-refresh now fires at most once per page session, regardless of how many times loadRecommendations / loadDashboard re-run (filter changes, tab re-entry, the post-refresh reload). One change fixes both the Opportunities and Home entry points, since both reload paths funnel through triggerAutoRefreshIfStale.

Explicit user actions (lookback change, manual refresh) call startRecommendationsRefresh directly and are intentionally not gated by the latch. A hard page reload resets module state, re-arming the once-per-load convenience. The fix does not just suppress the toast; it stops the redundant backend calls themselves.

Test

Added a regression test that reproduces the real scenario (collect POST resolves, freshness stays stale) and asserts the collect endpoint is hit exactly once across repeated loads and the post-refresh reload.

  • Pre-fix: FAILS (2 collect calls observed; the loop re-fires on reload).
  • Post-fix: PASSES (1 collect call).

The latch is reset in the recommendations test beforeEach so it cannot leak across tests.

Verification

  • npx tsc --noEmit: clean.
  • npx jest for the touched suites (recommendations, recommendations-permissions, recommendations-enabled-providers, recommendations-lookback, dashboard, navigation, dashboard-ownership-950, settings-accounts): 658 passed, 0 failed.

…e collector

triggerAutoRefreshIfStale fired a background re-collect whenever the
recommendations cache looked stale, and startRecommendationsRefresh's
success path called onReload() (loadRecommendations on Opportunities,
loadDashboard on Home), which re-entered triggerAutoRefreshIfStale. When
the backend collection never advances last_collected_at (e.g. it fails at
a DB error, so freshness stays stale forever) the success path re-fired
the collect on every reload with no terminal condition.

Observed in production at ~4-10 POST /recommendations/refresh per second,
sustained (~18k calls/hour). The calls currently fail fast at a DB error
before reaching Cost Explorer, but once the imminent DB fix lands every
iteration becomes a paid Cost Explorer / pricing call (~$0.01 each),
continuously. The existing autoRefreshInFlight guard only dedups
*concurrent* refreshes and is cleared per cycle, so it does not stop this
sequential re-trigger.

Add a once-per-page-session latch (autoRefreshAttempted) so the
stale-on-open auto-refresh fires at most once regardless of how many
times loadRecommendations / loadDashboard re-run (filter changes, tab
re-entry, the post-refresh reload). The latch is checked before the
freshness GET so neither the GET nor the collect repeats. Explicit user
actions (lookback change, manual refresh) call startRecommendationsRefresh
directly and are never gated by the latch. A hard page reload resets
module state, re-arming the once-per-load convenience.

Add a regression test that reproduces the real scenario (collect POST
resolves, freshness stays stale) and asserts the collect endpoint is hit
exactly once across repeated loads and the post-refresh reload. Fails
pre-fix (2 calls), passes post-fix (1 call). Reset the latch in the
recommendations test beforeEach so the latch cannot leak across tests.
@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm impact/all-users Affects every user effort/s Hours type/bug Defect labels Jun 9, 2026
@coderabbitai

coderabbitai Bot commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 7 minutes and 32 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6c52ae5a-ff84-4d7e-a346-8ba6d12f0952

📥 Commits

Reviewing files that changed from the base of the PR and between 31d83e7 and d27146e.

📒 Files selected for processing (2)
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/recommendations.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/recommendations-refresh-loop

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

@cristim

cristim commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 675390a into feat/multicloud-web-frontend Jun 9, 2026
5 checks passed
@cristim
cristim deleted the fix/recommendations-refresh-loop branch July 27, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/all-users Affects every user priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant