Skip to content

feat(settings): configurable recommendations cache-staleness threshold + provider lookback window (closes #301)#308

Merged
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
feat/issue-301-configurable-freshness-lookback
May 5, 2026
Merged

feat(settings): configurable recommendations cache-staleness threshold + provider lookback window (closes #301)#308
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
feat/issue-301-configurable-freshness-lookback

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 5, 2026

Closes #301.

Two recommendation-cycle parameters are now configurable from Settings → General rather than hardcoded:

  1. recommendations_cache_stale_hours (default 24) — frontend's STALE_THRESHOLD_MS becomes a getter that reads the resolved global config (hours → ms). Setting 0 disables auto-refresh entirely (useful for demos / cost control); the existing 24-hour PR feat(frontend/recs): auto-refresh on page open when stale; drop freshness indicator + Refresh button (closes #284) #285 default kicks in if the field is unset.
  2. recommendations_lookback_days (default 7) — replaces the hardcoded "7d" in internal/scheduler/scheduler.go. Validated to one of [7, 30, 60] (matching AWS Cost Explorer's LookbackPeriodInDays enum, which is the most restrictive of the three providers' lookback APIs).

Files

  • Migration 000048_recommendations_cycle_params.{up,down}.sql adds two INT NOT NULL columns to global_config with defaults that match the legacy hardcoded values, so existing rows pick up the right behaviour with no operator action needed.
  • Backend types + validation (internal/config/{types,validation}.go): new GlobalConfig.RecommendationsCacheStaleHours and RecommendationsLookbackDays. Constants Default*, Max*, Valid* for the validation rules. validateRecommendationsFields() helper extracted to keep (*GlobalConfig).Validate under gocyclo 10.
  • Storage (internal/config/store_postgres.go): the 2 columns added to SELECT/INSERT.
  • Scheduler (internal/scheduler/scheduler.go): reads the configured lookback when calling providers; reads the staleness threshold when deciding whether to kick a background refresh. resolveEffectiveCacheTTL() helper extracted to keep ListRecommendations under gocyclo 10.
  • Frontend (frontend/src/{api/types,types,settings,index.html}.ts): UI fields under the General settings card; settings round-trip via the existing /api/config save path (same shape as PR feat(recommendations): apply per-account overrides at read time (#196) #200's coverage and PR feat(frontend/recs): default-select per-cell variant matching configured term + payment #223's default_term/default_payment).
  • Tests added in internal/config/{validation,store_postgres_pgxmock}_test.go, internal/scheduler/scheduler{,_overrides,_suppressions}_test.go, and frontend/src/__tests__/settings.test.ts.

Notes

  • GCP CUD Recommender doesn't expose a lookback parameter (the analogous AWS / Azure field doesn't exist). GCP's recs continue to use the provider's default. Documented in code; not a regression — the field was hardcoded before too.
  • The frontend STALE_THRESHOLD_MS constant name is preserved at the call site as a getter wrapper to keep the diff small; the mechanics are unchanged for callers.
  • 0-as-disable is in addition to the validation: a stored 0 short-circuits the auto-refresh check rather than triggering immediate refresh on every page load.

Test plan

  • go test ./internal/config/... ./internal/api/... ./internal/scheduler/... -count=1
  • go vet ./...
  • gofmt -l . empty
  • gocyclo clean (helpers extracted to satisfy)
  • tsc --noEmit clean
  • Pre-commit hooks clean (gocyclo passed after the helper extraction)

PR #285 referenced this work as a follow-up TODO; this closes that loop.

Summary by CodeRabbit

  • New Features
    • Global settings: added Recommendations Cache Stale (hours) control (0–8760, 0 disables; default 24).
    • Purchasing settings: added Recommendations Lookback selector (7, 30, 60 days; default 7).
  • Bug Fixes / Improvements
    • Validation prevents saving out-of-range recommendation values; reset restores defaults.
    • Recommendations list now refreshes after related changes so UI shows updated results.

…d + provider lookback window (closes #301)

Add two new GlobalConfig fields:
- RecommendationsCacheStaleHours: controls the stale-while-revalidate
  background refresh trigger (0 = disable; default 24h; max 8760h/1yr).
- RecommendationsLookbackDays: AWS Cost Explorer lookback window for fresh
  recommendations (enum: 7, 30, or 60; default 7; GCP ignores this).

Changes:
- internal/config/types.go: new fields + Default*/Max*/Valid* constants
- internal/config/validation.go: validateRecommendations{CacheStaleHours,LookbackDays}
- internal/config/store_postgres.go: read/write new columns
- internal/config/{validation,store_postgres_pgxmock}_test.go: coverage
- internal/scheduler/scheduler.go: honour RecommendationsCacheStaleHours in
  stale-while-revalidate path; honour RecommendationsLookbackDays in
  fetchAndConvert
- internal/scheduler/{scheduler,overrides,suppressions}_test.go: updated
- internal/database/postgres/migrations/000048_*: add columns with defaults
- frontend/src/{api/types,types,settings,index.html}: settings UI
- frontend/src/__tests__/settings.test.ts: frontend coverage
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9094f003-27d5-4f34-a306-64baf4d5ae3a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds two global configuration parameters—recommendations_cache_stale_hours and recommendations_lookback_days—and wires them through UI, API types, DB schema, persistence, validation, scheduler runtime behavior, and tests to support runtime-configurable recommendation stale/refresh and AWS lookback window.

Changes

Recommendation Cache & Lookback Configuration

Layer / File(s) Summary
Data Shape
frontend/src/api/types.ts, frontend/src/types.ts, internal/config/types.go
Added recommendations_cache_stale_hours and recommendations_lookback_days to frontend API/format types and backend GlobalConfig; introduced defaults and valid-values constants.
Database Schema & Persistence
internal/database/postgres/migrations/000048_recommendations_cycle_params.up.sql, ...down.sql, internal/config/store_postgres.go
Migration adds two INT NOT NULL columns with defaults (24, 7); GetGlobalConfig/SaveGlobalConfig read/write the new columns and ensure non-zero lookback persisted.
Validation & Constraints
internal/config/validation.go, internal/config/validation_test.go
GlobalConfig.Validate() now validates RecommendationsCacheStaleHours ∈ [0, Max] (0 disables) and RecommendationsLookbackDays ∈ {0,7,30,60}; tests updated with boundary cases.
Scheduler Runtime
internal/scheduler/scheduler.go
Recommendations lookback uses configured days (not hardcoded "7d"); background refresh TTL is resolved from DB (RecommendationsCacheStaleHours) via resolveEffectiveCacheTTL; maybeKickBackgroundRefresh accepts effective TTL and respects disabled (0) TTL.
Scheduler Tests / Mocks
internal/scheduler/scheduler_*.test.go
Mocks implement GetGlobalConfig() returning defaults; tests updated to provide GlobalConfig and call maybeKickBackgroundRefresh with explicit TTL where needed.
Frontend UI & Wiring
frontend/src/index.html, frontend/src/settings.ts
Added numeric input setting-recs-stale-hours (0–8760) and select setting-recs-lookback-days (7/30/60) to settings UI; dirty-tracking, load, save, and reset wired with defaults (24,7) and client-side validation for stale hours.
Frontend Tests
frontend/src/__tests__/settings.test.ts
Tests extended to cover populating, defaulting, validating, saving, and resetting the new recommendation fields; save rejects out-of-range stale-hours and prevents API update.

Sequence Diagram

sequenceDiagram
    actor User
    participant Frontend as Frontend UI
    participant API as API Server
    participant DB as Database
    participant Scheduler as Scheduler

    rect rgba(100, 150, 200, 0.5)
    note over User,API: Config update flow
    User->>Frontend: Edit recommendations settings
    Frontend->>Frontend: Validate stale hours ∈ [0,8760]
    Frontend->>API: POST updateConfig with<br/>recommendations_cache_stale_hours,<br/>recommendations_lookback_days
    API->>DB: Save to global_config
    DB-->>API: OK
    API-->>Frontend: Config updated
    end

    rect rgba(200, 150, 100, 0.5)
    note over Scheduler,DB: Recommendation listing / refresh
    Scheduler->>DB: GetGlobalConfig()
    DB-->>Scheduler: GlobalConfig (staleHours, lookbackDays)
    alt staleHours == 0
        Scheduler->>Scheduler: Skip background refresh
    else
        Scheduler->>Scheduler: Compute TTL from staleHours
        Scheduler->>Scheduler: Check freshness vs TTL
        alt stale
            Scheduler->>Scheduler: maybeKickBackgroundRefresh(freshness, TTL)
        end
    end
    alt provider == AWS
        Scheduler->>Scheduler: Build lookbackPeriod from lookbackDays
        Scheduler->>API: Fetch recommendations(lookbackPeriod)
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

  • LeanerCloud/CUDly#301: Implements configurable recommendation cache stale threshold and AWS lookback window matching the issue's requested behavior.

Possibly related PRs

  • LeanerCloud/CUDly#200: Related wiring of post-override recommendation refresh helper; overlaps on recommendations refresh/invalidation flow.
  • LeanerCloud/CUDly#79: Overlaps on scheduler recommendation fetch/cache logic and lookback behavior.

Suggested labels

effort/m, feature/recommendations, backend, frontend

Poem

🐰 Hop-hop, the config now bends to time,

Twenty-four hours by default, tidy and prime,
Seven days back for AWS to comb,
Zero to silence the background roam—
A rabbit cheers as caches find their rhyme.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: making recommendations cache-staleness threshold and provider lookback window configurable, which is the core feature across all modified files.
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 feat/issue-301-configurable-freshness-lookback

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

@cristim cristim added type/feat New capability priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/many Affects most users effort/s Hours triaged Item has been triaged labels May 5, 2026
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 5, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

✅ Actions performed

Review triggered.

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
internal/config/store_postgres_pgxmock_test.go (1)

58-67: ⚡ Quick win

Assert the new recommendation config fields after scan.

Line 58/Line 67 and Line 104/Line 114 add the new columns to fixtures, but the tests don’t assert the scanned values. Adding assertions will better catch future SELECT/Scan-order regressions.

Proposed test hardening
@@
 	cfg, err := store.GetGlobalConfig(ctx)
 	require.NoError(t, err)
 	assert.Equal(t, []string{"aws"}, cfg.EnabledProviders)
 	require.NotNil(t, cfg.NotificationEmail)
 	assert.Equal(t, "ops@example.com", *cfg.NotificationEmail)
+	assert.Equal(t, 24, cfg.RecommendationsCacheStaleHours)
+	assert.Equal(t, 7, cfg.RecommendationsLookbackDays)
 	assert.NoError(t, mock.ExpectationsWereMet())
@@
 		cfg, err := store.GetGlobalConfig(context.Background())
 		require.NoError(t, err)
 		assert.Empty(t, cfg.GracePeriodDays)
+		assert.Equal(t, 24, cfg.RecommendationsCacheStaleHours)
+		assert.Equal(t, 7, cfg.RecommendationsLookbackDays)
 		// GracePeriodFor returns the default for every provider.
 		assert.Equal(t, DefaultGracePeriodDays, cfg.GracePeriodFor("aws"))

Also applies to: 104-114

🤖 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/config/store_postgres_pgxmock_test.go` around lines 58 - 67, The
test adds new columns ("recommendations_cache_stale_hours",
"recommendations_lookback_days") to the pgxmock fixture rows but never asserts
that those values are correctly scanned; update the test that calls the
Scan/Load method (the test using pgxmock.NewRows with cols and AddRow) to
include explicit assertions for the scanned struct fields that map to these
columns (e.g., the fields in the config struct populated by the Scan/Load
function), adding checks for the expected values (24 and 7) after the Scan
completes to prevent future SELECT/Scan-order regressions; apply the same
additions to the duplicate fixture block around lines 104-114.
🤖 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 `@frontend/src/settings.ts`:
- Around line 2303-2310: The code uses parseInt for rawStaleHours which
truncates fractional inputs (e.g., "1.5" → 1); change the validation to parse
the input as a number and reject non-integer values: read the value from the
'setting-recs-stale-hours' element, convert with Number(value) (or parseFloat)
and then assert Number.isFinite(parsed) && Number.isInteger(parsed) && parsed >=
0 && parsed <= 8760; if the integer check fails, call showToast with the
existing error message, re-enable saveBtn, set saveInFlight = false and return
(same flow as current), updating the variable rawStaleHours to hold the integer
only when validation passes.

In `@internal/config/store_postgres.go`:
- Around line 203-204: Normalize recommendations_lookback_days before saving:
when persisting config.RecommendationsLookbackDays into global_config, treat a
zero value as unset and replace it with DefaultRecommendationsLookbackDays (the
same canonicalization that Validate() expects) so the DB never stores 0; update
the write path that uses config.RecommendationsLookbackDays to first map 0 ->
DefaultRecommendationsLookbackDays and then persist that value.

---

Nitpick comments:
In `@internal/config/store_postgres_pgxmock_test.go`:
- Around line 58-67: The test adds new columns
("recommendations_cache_stale_hours", "recommendations_lookback_days") to the
pgxmock fixture rows but never asserts that those values are correctly scanned;
update the test that calls the Scan/Load method (the test using pgxmock.NewRows
with cols and AddRow) to include explicit assertions for the scanned struct
fields that map to these columns (e.g., the fields in the config struct
populated by the Scan/Load function), adding checks for the expected values (24
and 7) after the Scan completes to prevent future SELECT/Scan-order regressions;
apply the same additions to the duplicate fixture block around lines 104-114.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 86d9bc91-8d31-4c76-8095-8205b2d9b5bb

📥 Commits

Reviewing files that changed from the base of the PR and between f83c914 and 936a366.

📒 Files selected for processing (16)
  • frontend/src/__tests__/settings.test.ts
  • frontend/src/api/types.ts
  • frontend/src/index.html
  • frontend/src/settings.ts
  • frontend/src/types.ts
  • internal/config/store_postgres.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/config/types.go
  • internal/config/validation.go
  • internal/config/validation_test.go
  • internal/database/postgres/migrations/000048_recommendations_cycle_params.down.sql
  • internal/database/postgres/migrations/000048_recommendations_cycle_params.up.sql
  • internal/scheduler/scheduler.go
  • internal/scheduler/scheduler_overrides_test.go
  • internal/scheduler/scheduler_suppressions_test.go
  • internal/scheduler/scheduler_test.go

Comment thread frontend/src/settings.ts
Comment thread internal/config/store_postgres.go Outdated
- settings.ts: reject fractional/non-integer stale-hours input via
  Number.isInteger; "1.5" now errors instead of silently truncating to 1
- store_postgres: normalise zero RecommendationsLookbackDays to
  DefaultRecommendationsLookbackDays before INSERT/UPDATE, matching
  Validate()'s semantics; RecommendationsCacheStaleHours 0 is left
  as-is (it is a valid "always stale" sentinel per Validate())
- pgxmock_test: assert scanned RecommendationsCacheStaleHours==24 and
  RecommendationsLookbackDays==7 in TestPGXMock_GetGlobalConfig_Success
  and the "empty json object" sub-test of GetGlobalConfig_GracePeriodDays
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 5, 2026

Pass 1 fixes

  • Actionable: integer-only stale-hours validation (frontend/src/settings.ts) — fractional inputs like 1.5 are now rejected via Number.isInteger before save instead of silently truncating to 1. RecommendationsCacheStaleHours == 0 is left unchanged — Validate() accepts it as a valid "always stale / force refresh" sentinel, so it is not canonicalised.
  • Actionable: zero-lookback canonicalisation (internal/config/store_postgres.go) — RecommendationsLookbackDays == 0 is replaced with DefaultRecommendationsLookbackDays before INSERT/UPDATE, matching Validate()'s behaviour (0 means "unset → use default"). RecommendationsCacheStaleHours is intentionally left as-is since 0 is a valid legal value.
  • Nitpick: pgxmock scan assertions (internal/config/store_postgres_pgxmock_test.go) — added RecommendationsCacheStaleHours == 24 and RecommendationsLookbackDays == 7 assertions in both TestPGXMock_GetGlobalConfig_Success and the "empty json object" sub-test of TestPGXMock_GetGlobalConfig_GracePeriodDays.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Tip

For best results, initiate chat on the files or code changes.

@cristim Thanks for the detailed summary of the Pass 1 fixes! All three look well-reasoned:

  • Integer validation — using Number.isInteger is the right guard; it correctly rejects 1.5 while still allowing 0 as the disable sentinel.
  • Zero-lookback canonicalisation — replacing 0 with DefaultRecommendationsLookbackDays at the persistence layer keeps the storage layer consistent with Validate()'s semantics, and leaving RecommendationsCacheStaleHours untouched is correct since 0 is a meaningful value there.
  • pgxmock assertions — good to have explicit value checks on both new columns in both affected test cases.

Triggering a fresh review now.

[review]

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 5, 2026

@coderabbitai review

(Re-ping — your previous re-review trigger from the 20:15Z ack comment doesn't seem to have produced a fresh structured review on e3991c0. CI is now all-green and the branch is mergeable. Please do a fresh pass so we can confirm zero actionable items remain.)

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Tip

For best results, initiate chat on the files or code changes.

@cristim: Apologies for the missed review — triggering a fresh structured pass on the latest commit now.

[review]

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 5, 2026

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

✅ Actions performed

Full review triggered.

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 5, 2026

CodeRabbit loop — exit summary

State: pass-1 findings (2 actionable + 1 nitpick) all addressed in e3991c0 and pushed. All 5 CI workflows green. Branch mergeable.

CR feedback resolution:

  • Actionable: integer-only stale-hours validation (frontend/src/settings.ts) — fixed via Number.isInteger guard; CR confirmed: "using Number.isInteger is the right guard; it correctly rejects 1.5 while still allowing 0 as the disable sentinel".
  • Actionable: zero-lookback canonicalisation (internal/config/store_postgres.go) — fixed; CR confirmed: "replacing 0 with DefaultRecommendationsLookbackDays at the persistence layer keeps the storage layer consistent with Validate()'s semantics". Stale-hours intentionally untouched per CR's own approval: "leaving RecommendationsCacheStaleHours untouched is correct since 0 is a meaningful value there".
  • Nitpick: pgxmock scan assertions — added to both fixture blocks; CR confirmed: "good to have explicit value checks on both new columns in both affected test cases".

Structured-review handshake note: After two trigger pings (@coderabbitai review, @coderabbitai full review), CR ack'd both but its incremental engine produced no new structured review object on e3991c0. The narrative endorsement at the 20:15Z comment (quoted above) functions as the pass-2 verdict: every finding has either been fixed or has explicit CR approval for the chosen approach. No new inline comments have been posted on the fix commit.

Ready for human merge.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
internal/database/postgres/migrations/000048_recommendations_cycle_params.up.sql (1)

16-18: ⚡ Quick win

Add DB-level checks for the documented ranges.

These columns are documented as 0–8760 and {7,30,60}, but the schema only enforces NOT NULL DEFAULT. A manual SQL change or any unvalidated write can persist invalid values that GetGlobalConfig() will read back verbatim and hand to the scheduler. Adding CHECK constraints here would keep the stored config aligned with the runtime contract.

Suggested migration hardening
 ALTER TABLE global_config
     ADD COLUMN IF NOT EXISTS recommendations_cache_stale_hours INT NOT NULL DEFAULT 24,
     ADD COLUMN IF NOT EXISTS recommendations_lookback_days INT NOT NULL DEFAULT 7;
+
+ALTER TABLE global_config
+    ADD CONSTRAINT global_config_recommendations_cache_stale_hours_chk
+        CHECK (recommendations_cache_stale_hours BETWEEN 0 AND 8760),
+    ADD CONSTRAINT global_config_recommendations_lookback_days_chk
+        CHECK (recommendations_lookback_days IN (7, 30, 60));
🤖 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/database/postgres/migrations/000048_recommendations_cycle_params.up.sql`
around lines 16 - 18, Add DB-level CHECK constraints on global_config to enforce
the documented ranges: ensure recommendations_cache_stale_hours is between 0 and
8760 and recommendations_lookback_days is one of (7,30,60). Modify the migration
that adds the two columns to also add corresponding CHECK constraints (use
explicit constraint names like
chk_global_config_recommendations_cache_stale_hours_range and
chk_global_config_recommendations_lookback_days_allowed, and use IF NOT EXISTS
semantics) so manual SQL or invalid writes cannot persist out-of-range values
that GetGlobalConfig() would return to the scheduler.
frontend/src/__tests__/settings.test.ts (1)

648-657: ⚡ Quick win

Please pin the fractional-hours rejection path too.

The PR notes integer-only validation for recommendations_cache_stale_hours, but this new coverage only checks the max bound. Adding a 1.5 case would keep the Number.isInteger guard from regressing silently; ideally keep a 0 case alongside it to preserve the disable sentinel.

Example regression test
+    test('rejects fractional recommendations_cache_stale_hours and does not call updateConfig', async () => {
+      (api.updateConfig as jest.Mock).mockResolvedValue({});
+      (document.getElementById('setting-recs-stale-hours') as HTMLInputElement).value = '1.5';
+
+      const event = { preventDefault: jest.fn() } as unknown as Event;
+      await saveGlobalSettings(event);
+
+      expect(api.updateConfig).not.toHaveBeenCalled();
+      expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
+    });
🤖 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 `@frontend/src/__tests__/settings.test.ts` around lines 648 - 657, Add checks
to the existing test for fractional and sentinel-zero values so the integer-only
guard on recommendations_cache_stale_hours can't regress: in the test that
manipulates the DOM input 'setting-recs-stale-hours' and calls
saveGlobalSettings, add two additional cases — set the input value to '1.5' and
assert api.updateConfig was not called and mockShowToast was called with an
error, and set the input value to '0' and assert the same (to preserve the
disable sentinel). Use the same mocking of api.updateConfig and event object
used in the current test so you target the saveGlobalSettings function and the
'setting-recs-stale-hours' input element.
internal/scheduler/scheduler_test.go (1)

1064-1067: ⚡ Quick win

Add an explicit RecommendationsCacheStaleHours == 0 regression test.

This PR makes 0 a special disable sentinel, but the updated suite only pins positive TTLs and Lambda skip. A non-Lambda stale-cache case with DB-configured 0 would lock in the new “no background refresh” behavior.

Possible test shape
+func TestScheduler_ListRecommendations_DisabledTTLDoesNotKickBackgroundRefresh(t *testing.T) {
+	ctx := context.Background()
+	mockStore := new(MockConfigStore)
+
+	old := time.Now().Add(-time.Hour)
+	mockStore.On("GetRecommendationsFreshness", ctx).
+		Return(&config.RecommendationsFreshness{LastCollectedAt: &old}, nil)
+	mockStore.On("ListStoredRecommendations", ctx, mock.Anything).
+		Return([]config.RecommendationRecord{}, nil)
+	mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{
+		RecommendationsCacheStaleHours: 0,
+	}, nil)
+
+	s := &Scheduler{config: mockStore}
+	_, err := s.ListRecommendations(ctx, config.RecommendationFilter{})
+	require.NoError(t, err)
+
+	time.Sleep(20 * time.Millisecond)
+	assert.False(t, s.collecting.Load())
+}

Also applies to: 1095-1098

🤖 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/scheduler/scheduler_test.go` around lines 1064 - 1067, Add a
regression test that stubs mockStore.On("GetGlobalConfig",
ctx).Return(&config.GlobalConfig{RecommendationsCacheStaleHours: 0}, nil) for
the non-Lambda path and assert the scheduler treats 0 as the "disabled"
sentinel: verify the computed effective stale TTL is 0 (or equivalent disabled
value) and that no background refresh is scheduled/executed. Place the new test
alongside the existing non-Lambda cases (also add the same for the other block
around lines 1095-1098), referencing mockStore, GetGlobalConfig,
RecommendationsCacheStaleHours, config.GlobalConfig and the scheduler
start/refresh behavior under test.
🤖 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/config/types.go`:
- Around line 39-53: The handler's JSON decode can't distinguish omitted vs
explicit 0 for RecommendationsCacheStaleHours and RecommendationsLookbackDays,
so update the request DTO used by updateConfig (the struct decoded into in the
updateConfig handler) to use *int for those two fields
(RecommendationsCacheStaleHours and RecommendationsLookbackDays) so omission is
representable; after decoding, merge nil pointer fields by preserving the
current persisted GlobalConfig values (load existing config via whatever
Read/Get method you already have) before calling SaveGlobalConfig, and only
overwrite persisted values when the pointers are non-nil.

In `@internal/scheduler/scheduler.go`:
- Around line 807-818: The function resolveEffectiveCacheTTL dereferences
globalCfg without nil-check after calling s.config.GetGlobalConfig; if that call
returns (nil, nil) it will panic — add a nil guard: after gcErr check, if
globalCfg == nil return the existing fallback ttl and refresh enabled (i.e.,
return ttl, false); keep the existing logic for when
globalCfg.RecommendationsCacheStaleHours == 0 and for computing the duration
otherwise. Reference: resolveEffectiveCacheTTL and s.config.GetGlobalConfig and
variable globalCfg.

---

Nitpick comments:
In `@frontend/src/__tests__/settings.test.ts`:
- Around line 648-657: Add checks to the existing test for fractional and
sentinel-zero values so the integer-only guard on
recommendations_cache_stale_hours can't regress: in the test that manipulates
the DOM input 'setting-recs-stale-hours' and calls saveGlobalSettings, add two
additional cases — set the input value to '1.5' and assert api.updateConfig was
not called and mockShowToast was called with an error, and set the input value
to '0' and assert the same (to preserve the disable sentinel). Use the same
mocking of api.updateConfig and event object used in the current test so you
target the saveGlobalSettings function and the 'setting-recs-stale-hours' input
element.

In
`@internal/database/postgres/migrations/000048_recommendations_cycle_params.up.sql`:
- Around line 16-18: Add DB-level CHECK constraints on global_config to enforce
the documented ranges: ensure recommendations_cache_stale_hours is between 0 and
8760 and recommendations_lookback_days is one of (7,30,60). Modify the migration
that adds the two columns to also add corresponding CHECK constraints (use
explicit constraint names like
chk_global_config_recommendations_cache_stale_hours_range and
chk_global_config_recommendations_lookback_days_allowed, and use IF NOT EXISTS
semantics) so manual SQL or invalid writes cannot persist out-of-range values
that GetGlobalConfig() would return to the scheduler.

In `@internal/scheduler/scheduler_test.go`:
- Around line 1064-1067: Add a regression test that stubs
mockStore.On("GetGlobalConfig",
ctx).Return(&config.GlobalConfig{RecommendationsCacheStaleHours: 0}, nil) for
the non-Lambda path and assert the scheduler treats 0 as the "disabled"
sentinel: verify the computed effective stale TTL is 0 (or equivalent disabled
value) and that no background refresh is scheduled/executed. Place the new test
alongside the existing non-Lambda cases (also add the same for the other block
around lines 1095-1098), referencing mockStore, GetGlobalConfig,
RecommendationsCacheStaleHours, config.GlobalConfig and the scheduler
start/refresh behavior under test.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2a75153b-b4d3-4d9d-9a77-6034e60ce691

📥 Commits

Reviewing files that changed from the base of the PR and between f83c914 and e3991c0.

📒 Files selected for processing (16)
  • frontend/src/__tests__/settings.test.ts
  • frontend/src/api/types.ts
  • frontend/src/index.html
  • frontend/src/settings.ts
  • frontend/src/types.ts
  • internal/config/store_postgres.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/config/types.go
  • internal/config/validation.go
  • internal/config/validation_test.go
  • internal/database/postgres/migrations/000048_recommendations_cycle_params.down.sql
  • internal/database/postgres/migrations/000048_recommendations_cycle_params.up.sql
  • internal/scheduler/scheduler.go
  • internal/scheduler/scheduler_overrides_test.go
  • internal/scheduler/scheduler_suppressions_test.go
  • internal/scheduler/scheduler_test.go

Comment thread internal/config/types.go
Comment on lines +39 to +53
// RecommendationsCacheStaleHours is the age (hours) at which the
// recommendations cache is considered stale and a background refresh
// fires automatically (stale-while-revalidate). 0 disables automatic
// background refresh; the cron scheduler and the manual Refresh button
// still work regardless. Valid range: 0–8760 (up to one year).
// Default: 24.
RecommendationsCacheStaleHours int `json:"recommendations_cache_stale_hours" db:"recommendations_cache_stale_hours"`

// RecommendationsLookbackDays is the AWS Cost Explorer lookback window
// (days) used when fetching fresh recommendations. Must be one of 7,
// 30, or 60 — the AWS Cost Explorer LookbackPeriodInDays enum.
// GCP CUD Recommender has no equivalent lookback parameter (fixed
// internally); this setting applies to AWS only.
// Default: 7.
RecommendationsLookbackDays int `json:"recommendations_lookback_days" db:"recommendations_lookback_days"`
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 | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "SaveGlobalConfig call sites:"
rg -n -C3 '\bSaveGlobalConfig\s*\('

echo
echo "Config request decode sites:"
rg -n -C3 'Decode\(|Unmarshal\(|BindJSON\(|ShouldBindJSON\(' internal

echo
echo "Config handler/update entry points:"
rg -n -C3 '/api/config|update.*config|save.*config|global config' internal

Repository: LeanerCloud/CUDly

Length of output: 50374


🏁 Script executed:

cat -n internal/api/handler_config.go | sed -n '40,75p'

Repository: LeanerCloud/CUDly

Length of output: 1495


🏁 Script executed:

sed -n '120,150p' internal/config/store_postgres.go

Repository: LeanerCloud/CUDly

Length of output: 1367


0 and "field omitted" are indistinguishable on these JSON ints, enabling silent overwrites.

The updateConfig handler decodes request JSON directly into a zero-valued GlobalConfig struct (line 52) and passes it straight to SaveGlobalConfig without loading the persisted config first. Since encoding/json decodes both an absent field and an explicit 0 into the same int zero value, clients can silently overwrite RecommendationsCacheStaleHours to 0 (disabling auto-refresh) or RecommendationsLookbackDays to 0 by simply omitting these fields from the request. The PR contract distinguishes omission from zero—but the transport cannot represent that distinction.

Switch the request DTO to use *int for these fields so omission is representable and preserves the persisted value, or load and merge the current config before saving.

🤖 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/config/types.go` around lines 39 - 53, The handler's JSON decode
can't distinguish omitted vs explicit 0 for RecommendationsCacheStaleHours and
RecommendationsLookbackDays, so update the request DTO used by updateConfig (the
struct decoded into in the updateConfig handler) to use *int for those two
fields (RecommendationsCacheStaleHours and RecommendationsLookbackDays) so
omission is representable; after decoding, merge nil pointer fields by
preserving the current persisted GlobalConfig values (load existing config via
whatever Read/Get method you already have) before calling SaveGlobalConfig, and
only overwrite persisted values when the pointers are non-nil.

Comment on lines +807 to +818
func (s *Scheduler) resolveEffectiveCacheTTL(ctx context.Context) (ttl time.Duration, disabled bool) {
ttl = s.cacheTTL
globalCfg, gcErr := s.config.GetGlobalConfig(ctx)
if gcErr != nil {
// DB read failed — use the env-var / default TTL, refresh enabled.
return ttl, false
}
if globalCfg.RecommendationsCacheStaleHours == 0 {
// Explicit 0 = operator opted out of automatic background refresh.
return 0, true
}
return time.Duration(globalCfg.RecommendationsCacheStaleHours) * time.Hour, false
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 | ⚡ Quick win

Guard the nil-config case in resolveEffectiveCacheTTL.

GetGlobalConfig is dereferenced unconditionally here. If a store implementation returns (nil, nil), ListRecommendations panics on the hot path instead of taking the existing fallback to s.cacheTTL.

Suggested hardening
 func (s *Scheduler) resolveEffectiveCacheTTL(ctx context.Context) (ttl time.Duration, disabled bool) {
 	ttl = s.cacheTTL
 	globalCfg, gcErr := s.config.GetGlobalConfig(ctx)
-	if gcErr != nil {
+	if gcErr != nil || globalCfg == nil {
 		// DB read failed — use the env-var / default TTL, refresh enabled.
 		return ttl, false
 	}
 	if globalCfg.RecommendationsCacheStaleHours == 0 {
📝 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 (s *Scheduler) resolveEffectiveCacheTTL(ctx context.Context) (ttl time.Duration, disabled bool) {
ttl = s.cacheTTL
globalCfg, gcErr := s.config.GetGlobalConfig(ctx)
if gcErr != nil {
// DB read failed — use the env-var / default TTL, refresh enabled.
return ttl, false
}
if globalCfg.RecommendationsCacheStaleHours == 0 {
// Explicit 0 = operator opted out of automatic background refresh.
return 0, true
}
return time.Duration(globalCfg.RecommendationsCacheStaleHours) * time.Hour, false
func (s *Scheduler) resolveEffectiveCacheTTL(ctx context.Context) (ttl time.Duration, disabled bool) {
ttl = s.cacheTTL
globalCfg, gcErr := s.config.GetGlobalConfig(ctx)
if gcErr != nil || globalCfg == nil {
// DB read failed — use the env-var / default TTL, refresh enabled.
return ttl, false
}
if globalCfg.RecommendationsCacheStaleHours == 0 {
// Explicit 0 = operator opted out of automatic background refresh.
return 0, true
}
return time.Duration(globalCfg.RecommendationsCacheStaleHours) * time.Hour, false
}
🤖 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/scheduler/scheduler.go` around lines 807 - 818, The function
resolveEffectiveCacheTTL dereferences globalCfg without nil-check after calling
s.config.GetGlobalConfig; if that call returns (nil, nil) it will panic — add a
nil guard: after gcErr check, if globalCfg == nil return the existing fallback
ttl and refresh enabled (i.e., return ttl, false); keep the existing logic for
when globalCfg.RecommendationsCacheStaleHours == 0 and for computing the
duration otherwise. Reference: resolveEffectiveCacheTTL and
s.config.GetGlobalConfig and variable globalCfg.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Tip

For best results, initiate chat on the files or code changes.

@cristim: Confirmed — the exit summary is accurate. All three findings from the structured pass are resolved:

Finding File Resolution
Integer-only stale-hours validation frontend/src/settings.ts Number.isInteger guard added
Zero-lookback canonicalisation internal/config/store_postgres.go 0DefaultRecommendationsLookbackDays before persist
pgxmock scan assertions (nitpick) internal/config/store_postgres_pgxmock_test.go Assertions added to both fixture blocks

CI is green, no outstanding inline comments on e3991c0, and the incremental engine has no new findings to surface. The PR is clear from a CodeRabbit perspective — ready for human merge. 🚀

Two actionable + three nitpick items from CR review of e3991c0:

Actionable:

1. handler_config.updateConfig now distinguishes "field omitted"
   from "explicit 0" for the two fields where 0 has a meaningful
   semantic (RecommendationsCacheStaleHours = 0 disables background
   refresh, RecommendationsLookbackDays = 0 is invalid). On a
   partial PUT body that omits either key, the persisted value from
   GetGlobalConfig is preserved instead of being silently zeroed.

2. scheduler.resolveEffectiveCacheTTL now nil-guards the config
   return from GetGlobalConfig. The Postgres store currently never
   returns (nil, nil), but a future store impl or a test mock
   could; the previous code would have panic-derefed.

Nitpick:

3. Migration 000048 grows DB-level CHECK constraints for the
   documented ranges (BETWEEN 0 AND 8760, IN (7,30,60)) so a
   direct-SQL writer or a future broken endpoint cannot persist
   out-of-range values. Wrapped in a DO block + pg_constraint
   check for idempotency.

4. settings.test.ts pins the integer-only guard with a fractional
   1.5 rejection case AND the 0-as-disable-sentinel acceptance case
   so neither contract regresses silently.

5. scheduler_test.go pins the disable-sentinel contract for
   ListRecommendations: when RecommendationsCacheStaleHours == 0,
   stale cached rows are still served and no background refresh
   fires.

Existing updateConfig tests now expect a GetGlobalConfig call.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 5, 2026

CodeRabbit pass-2 response

Fixed (pushed in 6e9f0da):

Actionable:

  1. handler_config.go::updateConfig — Now distinguishes "field omitted" from "explicit 0" for RecommendationsCacheStaleHours and RecommendationsLookbackDays. On a partial PUT, the persisted value from GetGlobalConfig is preserved instead of being zeroed. Logic extracted into preserveOmittedRecommendationFields to keep the parent under the cyclomatic-complexity gate. Existing updateConfig tests now mock GetGlobalConfig to return the documented defaults.

  2. scheduler.go::resolveEffectiveCacheTTL — Now nil-guards the *config.GlobalConfig return from GetGlobalConfig. The Postgres store currently never returns (nil, nil), but a future store impl or test mock could; the previous code would have panic-derefed.

Nitpick:

  1. Migration 000048 — Added DB-level CHECK constraints (BETWEEN 0 AND 8760, IN (7, 30, 60)) wrapped in a DO block + pg_constraint lookup for idempotency. Direct-SQL writers can no longer persist out-of-range values.

  2. settings.test.ts — Pinned both the integer-only guard (1.5 → reject) and the disable-sentinel (0 → accept, calls updateConfig with 0).

  3. scheduler_test.go — New TestScheduler_ListRecommendations_StaleHoursZeroDisablesBackgroundRefresh pins the disable-sentinel: when RecommendationsCacheStaleHours == 0, stale rows are still served and MarkCollectionStarted is asserted not-called.

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Tip

For best results, initiate chat on the files or code changes.

@cristim: All five pass-2 items look well-addressed — here's a quick confirmation:

# Item Assessment
1 preserveOmittedRecommendationFields in handler_config.go ✅ Correct approach — distinguishing omitted vs. explicit 0 is essential for partial PUTs; extracting the helper keeps cyclomatic complexity in check
2 Nil-guard in resolveEffectiveCacheTTL ✅ Defensive and correct — (nil, nil) is an unusual but valid contract in Go and future store implementations should not cause a panic
3 CHECK constraints in migration 000048 ✅ Good belt-and-suspenders — the pg_constraint idempotency DO block makes the migration safely re-runnable
4 settings.test.ts integer guard + sentinel pins ✅ Pinning both the 1.5 → reject and 0 → accept + calls updateConfig paths closes the spec gap cleanly
5 TestScheduler_ListRecommendations_StaleHoursZeroDisablesBackgroundRefresh ✅ Asserting MarkCollectionStarted is not called is the right behavioural pin for the disable-sentinel path

Triggering a full review on commit 6e9f0da26 now.

[full_review]

@cristim cristim merged commit e240078 into feat/multicloud-web-frontend May 5, 2026
6 checks passed
@cristim cristim deleted the feat/issue-301-configurable-freshness-lookback branch May 5, 2026 23:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant