verify recommendations engine actually consumes per-account service overrides · Issue #111 · LeanerCloud/cloud-commitments-cli · GitHub
Skip to content

verify recommendations engine actually consumes per-account service overrides #111

Closed

Description

@cristim

Summary

PRs #72 (inline payment edit) and #106 (create modal) made account_service_overrides configurable through the UI. The schema and write path have existed since migration 000011_cloud_accounts.up.sql:78 (provider-scoped, account-scoped overrides for enabled/term/payment/coverage/ramp_schedule/include-exclude arrays). What hasn't been verified end-to-end is whether the recommendations engine actually consumes those overrides when generating per-account recommendations.

If the engine reads only the global service_configs rows, the entire override system is decorative — every PUT under /api/accounts/:id/service-overrides/... lands in the DB and disappears, while recommendations continue to use the global term/payment.

What needs verifying

For each plausible code path, trace whether the per-account override is read at the right point:

  1. Recommendations collection (scheduler / on-demand) — internal/scheduler/scheduler.go populates RecommendationParams.Term/PaymentOption from globalCfg.DefaultTerm/DefaultPayment at ~scheduler.go:585-598 (per the PR feat(commitment-options): probe AWS reserved-offerings live & validate saves #54 exploration findings). Does it also consult account_service_overrides per account when iterating accounts? Grep for any read of account_service_overrides outside the API handlers.
  2. Per-service recommendation generation — providers/aws/recommendations/parser_*.go files. Does the parser receive overridden term/payment via params, or does it read the global ServiceConfig directly?
  3. Purchase execution — internal/purchase/execution.go. The execution path reads term/payment from RecommendationRecord.Term/PaymentOption (already stamped at recommendation time), not from config — so by then the override should already have been applied. The question is whether the override reached the recommendation record in the first place.
  4. Coverage calculation — coverage is per-row on overrides. Does the engine apply the override or only the global default?
  5. Disabled overrides — when enabled = false on an override row, does the engine skip that account's recommendations for that service, or does it ignore the flag?

Likely outcome of the trace: most of these read the global config and ignore overrides. If so, this issue's resolution is wiring per-account override lookup at the recommendation-collection point — one helper, called per (account_id, provider, service), that overlays the override's set fields on top of the global ServiceConfig.

Why this matters

Acceptance criteria

  • A documented trace (PR or issue comment) showing exactly which code path reads each per-account override field, and where the override merges with the global default.
  • An integration test in internal/scheduler/ or providers/aws/recommendations/ that:
    • Seeds a global ServiceConfig with term=3, payment=all-upfront for (aws, rds)
    • Seeds an account_service_overrides row for an account with term=1, payment=no-upfront on (aws, rds)
    • Triggers recommendations collection for that account
    • Asserts the resulting RecommendationRecord has term=1, payment=no-upfront (not the global values)
  • If the trace finds the override is not consumed today, file a follow-up bug under this issue's umbrella with the specific call sites that need updating, and add account_service_overrides to whichever lookup path is missing them.

Out of scope

  • Adding new override fields. The schema already covers the fields the UI surfaces.
  • UI changes — the goal is to verify (and if needed, fix) the backend integration, not change the front of house.

Activity

  1. cristim commented on Apr 28, 2026

    @cristim
    MemberAuthor

    P1/severity:critical — if per-account overrides are not consumed by the recommendations engine (currently unverified), then every UI interaction since #72 and #106 has been a no-op on actual recommendations. This is potentially the most impactful bug in the overrides cluster: issues #107 and #108 both assume this data flows through to recommendations. Needs an urgent trace + integration test to confirm the round trip. (triage agent wave1-B)

  2. cristim commented on Apr 28, 2026

    @cristim
    MemberAuthor

    Trace findings — overrides ARE decorative, confirmed

    Audited every consumer of account_service_overrides reads + every reader of service_configs.Default* in the recommendation/purchase pipeline. Conclusion: the override system is fully implemented at the data layer (schema, CRUD API, UI in #72/#106, resolver in internal/config/resolver.go) but completely unwired from the recommendation pipeline. Every override an operator saves via the UI lands in the DB and stays there; the engine never reads it.

    Where the override IS read today

    $ rg 'GetAccountServiceOverride|ListAccountServiceOverrides' --type=go --no-tests
    internal/api/handler_accounts.go:886    h.config.ListAccountServiceOverrides(ctx, id)         # serves GET …/service-overrides
    internal/api/handler_accounts.go:921    h.config.GetAccountServiceOverride(ctx, ...)          # serves PUT …/service-overrides/:p/:s (read-modify-write)
    

    Both call sites are in the override CRUD handlers themselves — read-your-own-overrides. Zero callers in the scheduler, the AWS/Azure/GCP providers, the recommendations parser, or the purchase manager.

    Where ResolveServiceConfig is called

    $ rg 'ResolveServiceConfig' --type=go --no-tests
    internal/config/resolver.go:11    func ResolveServiceConfig(...)
    

    The function is implemented and unit-tested (internal/config/resolver_test.go — 5 tests covering nil-override, all-fields-overridden, sparse, empty-slice-doesn't-override, no-mutation-of-global), but has zero production call sites. The merge logic that would produce a per-account-effective ServiceConfig is dead code.

    Where the global ServiceConfig fields ARE read (i.e., where the override SHOULD have been merged in)

    $ rg 'globalCfg\.Default(Term|Payment|Coverage)' --type=go --no-tests
    internal/scheduler/scheduler.go:588    Term:           fmt.Sprintf("%dyr", globalCfg.DefaultTerm),
    internal/scheduler/scheduler.go:589    PaymentOption:  globalCfg.DefaultPayment,
    internal/api/handler_dashboard.go:124  if globalCfg != nil && globalCfg.DefaultCoverage > 0 {
    internal/api/handler_config.go:72      svc.Term = cfg.DefaultTerm
    internal/api/handler_config.go:73      svc.Payment = cfg.DefaultPayment
    internal/purchase/manager.go:33,99     defaults.Term = cfg.DefaultTerm; defaults.Payment = cfg.DefaultPaymentOption
    

    Six call sites where the global default is consulted; none of them look up the per-account override.

    Architectural fork — where SHOULD the override be wired in?

    The cloud APIs (Cost Explorer, Azure Advisor, GCP Recommender) typically return multiple recommendations per resource — one per (term, payment) combination. The current pipeline collects all combos and stores all of them. The override "filter" could plausibly live at three different points:

    Point What changes Pros Cons
    A. Collection-time (scheduler.go, fetchAndConvert) Per account, look up override, only fetch / store the matching (term, payment) recs Smaller cache, fewer Cost Explorer API calls Loses the "what would 1yr-no-upfront save instead?" comparison view; recollect needed when an override changes
    B. Read-time filter (handler_recommendations.go, ListRecommendations) Cache stays full; per-account-resolved (term, payment) filters which cached recs are returned Override changes take effect on the next page load with no recollect; comparison view still possible by ignoring the per-account filter Read path becomes account-aware (currently it isn't); slightly more complex SQL
    C. Purchase-time only (purchase/manager.go) Listing shows all combos; the OVERRIDE only affects which (term, payment) the purchase actually buys Minimal change — purchase manager already has a defaults concept Recommendations UI continues to show all combos, ignoring the user's stated preference; Plan-driven scheduled purchases still need (A) or (B) to actually buy the right thing

    The acceptance criterion in the original issue ("trigger recommendations collection for that account → assert the resulting RecommendationRecord has term=1, payment=no-upfront") points at (A), but (B) is closer to current behaviour and avoids losing the comparison view. (C) is the smallest diff but doesn't satisfy the acceptance test.

    Suggested next step

    Pick the architecture (I'd lean (B) for behaviour-preservation), then a follow-up PR can:

    1. Add ResolveServiceConfig calls at the chosen point.
    2. Add Term / Payment to RecommendationFilter (currently only provider/service/region/account_ids).
    3. Have ListStoredRecommendations consult the per-account override when account_ids is supplied.
    4. Add the integration test from the acceptance criteria.

    Happy to implement once the architecture call is made — leaving this issue open for that decision since picking unilaterally would either break the comparison view (A) or under-deliver vs the stated acceptance criterion (C).

  3. cristim commented on Apr 29, 2026

    @cristim
    MemberAuthor

    I think the default override to just be pre-populated at purchase time, but allowing the user to select other options if desired

  4. cristim commented on Apr 29, 2026

    @cristim
    MemberAuthor

    Investigation note for the purchase-time pre-fill design (request from 2026-04-29):

    Override schema is fine. AccountServiceOverride (internal/config/types.go:450) carries Term, Payment, Coverage, Include/Exclude Engines/Regions/Types — every field a purchase form would need. Backend GET endpoint /api/accounts/:id/service-overrides already exists (handler at internal/api/handler_accounts.go:871) and the frontend client api.listAccountServiceOverrides(id) is wired (frontend/src/api/accounts.ts:155). No new endpoint needed.

    But the Recommendations page has no per-rec purchase form to pre-populate. The two purchase paths today:

    1. Per-row openPurchaseModal (recommendations.ts:1674): renders a read-only summary table — service, type, region, count, savings. No editable Term/Payment/Capacity fields. The rec's own term and payment (set by the scheduler at collection time) flow straight through to the executePurchase POST.
    2. Bulk-purchase toolbar (bulk-purchase-payment + bulk-purchase-capacity): ONE Payment + ONE Capacity for the whole batch. Term is per-rec (the toolbar Term selector was removed in Bundle B per the comment at recommendations.ts:1125). The fan-out modal that follows splits the batch into buckets keyed by (provider, service, term) but every bucket inherits the toolbar's single Payment.

    So "pre-populate at purchase time" maps to one of three plausible scopes — picking is a design choice, not an implementation choice:

    • (i) Toolbar-level seeding when bulk has a single (account, service): when every selected rec shares one (provider, account, service), look up that override and use its Payment as the toolbar default. When mixed, fall back. Smallest change. But only helps the single-cell case.
    • (ii) Per-bucket payment in the fan-out modal: extend the fan-out modal so each bucket has its own Payment dropdown, default-seeded from the bucket's override; the user can override each. Bigger UI change, but matches the "per-account override" intent more faithfully across mixed bulks.
    • (iii) Add an editable per-row preview to openPurchaseModal: turn that read-only summary into a tiny form (Term + Payment per row) seeded from the override. Biggest change; closest to the literal "open purchase modal → see prefilled defaults" reading.

    Stopping here per the directive to avoid silently switching scope. Could you pick (i), (ii), (iii), or describe a fourth interpretation? Once the scope is clear I can resume.

    (Recommendations themselves stay unfiltered in all three options, per your guidance.)

  5. added 2 commits that reference this issue on Apr 29, 2026
    cbe11b6
    ab0ce9c
  6. cristim commented on Apr 29, 2026

    @cristim
    MemberAuthor

    Implementation update — sub-options (ii) + (iii) are landed in PR #193 (force-pushed onto rebased base; CR re-pinged).

    What's done in #193:

    • (ii) Per-bucket Payment dropdown in the fan-out modal, default-seeded from each bucket's per-account override when single-account.
    • (iii) Per-row editable Term + Payment in openPurchaseModal, default-seeded by override → rec.payment → fallback. Also fixes a pre-existing bug where handleExecutePurchase hardcoded 'all-upfront' for the single-bucket path.

    Follow-up issues filed for the work that's NOT in #193 but came up during analysis/implementation:

    Recommend keeping #111 open until #196 lands (it's the substantive remaining work). Closing PR #193 doesn't close this issue.

  7. cristim commented on Apr 29, 2026

    @cristim
    MemberAuthor

    Wrap-up — closing the loop

    The work proposed in this issue is complete across two PRs:

    Validation

    Automated:

    • Backend: internal/config/recommendation_overrides_test.go (10 tests), internal/scheduler/scheduler_overrides_test.go (9 tests including the issue acceptance criterion), internal/api/handler_dashboard_test.go (8 cases for per-account coverage scaling).
    • Frontend: 9 new tests in frontend/src/__tests__/recommendations.test.ts covering both modals' override seed behaviour, plus 2 in settings-accounts.test.ts for refetch wiring.

    Manual UI smoke (deployed instance, 2026-04-30): walked the full "set override → see effect at purchase time" path:

    • Created aws/ec2 → Partial Upfront override on the CUDly host AWS account
    • Single-bucket purchase modal: Payment dropdown pre-selected partial-upfront with (from account override) label rendered ✅
    • Multi-bucket fan-out modal (1yr + 3yr selection): both buckets pre-seeded partial-upfront with (from account override) label on each ✅
    • Reset override → both modals fall back to global default

    GIF + zoomed screenshots from the smoke run captured locally; happy to share on request.

    Follow-ups still open

    Closing.

  8. added 3 commits that reference this issue on May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions