Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,9 @@ func (m *mockConfigStore) UpsertRecommendations(_ context.Context, _ time.Time,
func (m *mockConfigStore) ListStoredRecommendations(_ context.Context, _ config.RecommendationFilter) ([]config.RecommendationRecord, error) {
return nil, nil
}
func (m *mockConfigStore) ListStoredRecommendationsChunk(_ context.Context, _ config.RecommendationFilter, _ *config.RecommendationScanKey, _ int) (config.RecommendationChunk, error) {
return config.RecommendationChunk{Done: true}, nil
}
func (m *mockConfigStore) GetRecommendationsFreshness(_ context.Context) (*config.RecommendationsFreshness, error) {
return &config.RecommendationsFreshness{}, nil
}
Expand Down
102 changes: 78 additions & 24 deletions internal/api/handler_recommendations.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ func (h *Handler) getRecommendations(ctx context.Context, req *events.LambdaFunc
if err != nil {
return nil, err
}
page, err := parseRecommendationsPage(params)
if err != nil {
return nil, err
}

// Resolve the account scope once: it decides both whether this read may
// kick a background collection (issue #627) and the post-filter below.
Expand All @@ -102,6 +106,10 @@ func (h *Handler) getRecommendations(ctx context.Context, req *events.LambdaFunc
return nil, fmt.Errorf("failed to get allowed accounts: %w", err)
}

if page.enabled {
return h.getRecommendationsPage(ctx, scope, filter, page)
}

recommendations, err := h.scheduler.ListRecommendations(recommendationsReadContext(ctx, scope), filter)
if err != nil {
return nil, fmt.Errorf("failed to get recommendations: %w", err)
Expand Down Expand Up @@ -142,10 +150,18 @@ func (h *Handler) filterRecommendationsByScope(ctx context.Context, allowedAccou
if allowedAccounts.AllowsAll() {
return recs, nil
}
nameByID, err := h.cloudAccountNamesByID(ctx)
if err != nil {
return nil, err
}
return filterRecommendationsInScope(allowedAccounts, nameByID, recs), nil
}

// Resolve account names so the allowed list can match on either ID or
// display name. Recommendations only carry CloudAccountID, so the name
// lookup needs a one-time fetch of the account list.
// cloudAccountNamesByID resolves account names so an allowed-accounts list
// can match on either ID or display name. Recommendations only carry
// CloudAccountID, so the name lookup needs a one-time fetch of the account
// list.
func (h *Handler) cloudAccountNamesByID(ctx context.Context) (map[string]string, error) {
accounts, err := h.config.ListCloudAccounts(ctx, config.CloudAccountFilter{})
if err != nil {
return nil, fmt.Errorf("failed to list accounts for filter: %w", err)
Expand All @@ -155,7 +171,12 @@ func (h *Handler) filterRecommendationsByScope(ctx context.Context, allowedAccou
a := accounts[_rvc]
nameByID[a.ID] = a.Name
}
return nameByID, nil
}

// filterRecommendationsInScope keeps the recs whose account allowedAccounts
// allows, in place and in order. Recs without an account are dropped.
func filterRecommendationsInScope(allowedAccounts auth.AccountScope, nameByID map[string]string, recs []config.RecommendationRecord) []config.RecommendationRecord {
filtered := recs[:0]
for _rvc := range recs {
rec := recs[_rvc]
Expand All @@ -167,7 +188,7 @@ func (h *Handler) filterRecommendationsByScope(ctx context.Context, allowedAccou
filtered = append(filtered, rec)
}
}
return filtered, nil
return filtered
}

// filterRecommendationsByEnabledProviders removes recommendations for providers
Expand Down Expand Up @@ -195,17 +216,22 @@ func (h *Handler) filterRecommendationsByEnabledProviders(ctx context.Context, r
}

// buildRecommendationsResponse calculates summary statistics and collects
// unique regions from a set of recommendations.
// unique regions from a set of recommendations, returning all of them as rows
// (the legacy unpaginated shape).
func buildRecommendationsResponse(recommendations []config.RecommendationRecord) *RecommendationsResponse {
regionSet := make(map[string]struct{})
var totalSavings, totalUpfront float64
for i := range recommendations {
rec := &recommendations[i]
totalSavings += rec.Savings
totalUpfront += rec.UpfrontCost
if rec.Region != "" {
regionSet[rec.Region] = struct{}{}
}
return summarizeRecommendations(recommendations, recommendations)
}

// summarizeRecommendations computes the summary and region list over all (the
// full filtered set) and returns rows as the response body. rows is either all
// itself or one page of it (issue #723), so the totals never shrink to the
// page.
func summarizeRecommendations(all, rows []config.RecommendationRecord) *RecommendationsResponse {
var summary recommendationsSummary
for i := range all {
summary.add(&all[i])
}
for i := range rows {
// Surface the compute size (VCPU / MemoryGB) at the top level so
// the frontend Capacity column can render it without parsing the
// opaque Details blob. The canonical values live in the typed
Expand All @@ -214,26 +240,54 @@ func buildRecommendationsResponse(recommendations []config.RecommendationRecord)
// pointers when this is a compute rec with a known size. Non-compute
// recs, legacy rows with empty Details, and decode failures all
// leave the pointers nil, so JSON omits them and the frontend
// renders "—".
stampComputeCapacity(rec)
// renders "—". Only returned rows are decoded.
stampComputeCapacity(&rows[i])
}
return summary.response(rows)
}

// recommendationsSummary accumulates the full-set totals and region list of
// a list response one rec at a time, so a paginated scan can total every
// filtered row without holding them all (issue #981).
type recommendationsSummary struct {
count int
totalSavings float64
totalUpfront float64
regionSet map[string]struct{}
}

func (s *recommendationsSummary) add(rec *config.RecommendationRecord) {
s.count++
s.totalSavings += rec.Savings
s.totalUpfront += rec.UpfrontCost
if rec.Region == "" {
return
}
if s.regionSet == nil {
s.regionSet = make(map[string]struct{})
}
s.regionSet[rec.Region] = struct{}{}
}

regions := make([]string, 0, len(regionSet))
for r := range regionSet {
// response returns rows as the response body with the accumulated summary
// and regions.
func (s *recommendationsSummary) response(rows []config.RecommendationRecord) *RecommendationsResponse {
regions := make([]string, 0, len(s.regionSet))
for r := range s.regionSet {
regions = append(regions, r)
}

var avgPayback float64
if totalSavings > 0 {
avgPayback = totalUpfront / totalSavings
if s.totalSavings > 0 {
avgPayback = s.totalUpfront / s.totalSavings
}

return &RecommendationsResponse{
Recommendations: recommendations,
Recommendations: rows,
Summary: RecommendationsSummary{
TotalCount: len(recommendations),
TotalMonthlySavings: totalSavings,
TotalUpfrontCost: totalUpfront,
TotalCount: s.count,
TotalMonthlySavings: s.totalSavings,
TotalUpfrontCost: s.totalUpfront,
AvgPaybackMonths: avgPayback,
},
Regions: regions,
Expand Down
166 changes: 166 additions & 0 deletions internal/api/handler_recommendations_page.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package api

import (
"encoding/base64"
"encoding/json"
"fmt"
"strconv"
"strings"

"github.com/LeanerCloud/cloud-commitments-go/pkg/logging"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
)

// Opt-in cursor pagination for GET /api/recommendations (refs issue #723).
//
// The list endpoint used to return the whole filtered cache in one body,
// which passes the 6 MB Lambda response cap once a tenant accumulates a few
// thousand recommendations. A caller that sends `limit` (and, for later
// pages, the `cursor` returned as `next_cursor`) gets one bounded page.
// A paginated request scans the filtered cache in ID-ordered chunks and keeps
// only the page (issue #981, see handler_recommendations_scan.go). A
// page is bounded twice: by row count (limit) and by serialized bytes
// (recommendationsPageByteBudget), because the opaque Details blob makes the
// size of a row data-dependent.
// A request carrying neither parameter keeps the existing unpaginated shape
// unchanged, so the current frontend (which sorts, groups and totals the
// full set client-side) keeps working until it adopts pagination.
//
// In both modes `summary` and `regions` describe the full filtered set, not
// just the returned page, so headline totals stay correct while paging.
const (
// DefaultRecommendationsPageSize is the page size used when a caller
// sends a cursor without a limit.
DefaultRecommendationsPageSize = 500

// MaxRecommendationsPageSize caps the row count of one page. It is an
// upper bound only: recommendationsPageByteBudget can cut a page shorter.
MaxRecommendationsPageSize = 1000

// recommendationsPageByteBudget caps the bytes one page adds to the
// Lambda response payload. The Lambda limit (6 MiB, 6291456 bytes)
// applies to the whole invoke response: the JSON envelope with status
// code and headers, plus the body re-encoded as a JSON string, which
// escapes every quote and backslash again. Rows are therefore measured
// as they appear inside that string (see envelopeLen), and the budget is
// checked against uncompressed bytes because the #775 gzip only runs
// when the client sends Accept-Encoding: gzip. 4 MiB leaves about 2 MiB
// for the envelope, headers and next_cursor, which are a few KiB.
recommendationsPageByteBudget = 4 << 20

// maxRecommendationsCursorLen bounds the encoded cursor so a hostile
// query string cannot force a large decode.
maxRecommendationsCursorLen = 1024
)

// recommendationsPage is the parsed pagination request. enabled is false when
// the caller sent neither limit nor cursor (legacy unpaginated mode).
type recommendationsPage struct {
enabled bool
limit int
afterID string
}

// parseRecommendationsPage validates the optional limit and cursor query
// parameters. limit must be an integer in 1..MaxRecommendationsPageSize;
// out-of-range values are rejected rather than clamped so a client bug does
// not silently return a different page size than it asked for. Legacy
// unpaginated mode is selected only when neither parameter is present: a
// present but empty value (?limit= or ?cursor=) is a 400, so a client bug
// cannot silently fall back to the unbounded response.
func parseRecommendationsPage(params map[string]string) (recommendationsPage, error) {
rawLimit, hasLimit := params["limit"]
rawCursor, hasCursor := params["cursor"]
if !hasLimit && !hasCursor {
return recommendationsPage{}, nil
}
// From here a present but empty value fails the checks below: "" is not
// an integer, and an empty cursor does not decode to an ID.
rawLimit = strings.TrimSpace(rawLimit)
rawCursor = strings.TrimSpace(rawCursor)

page := recommendationsPage{enabled: true, limit: DefaultRecommendationsPageSize}
if hasLimit {
n, err := strconv.Atoi(rawLimit)
if err != nil || n < 1 || n > MaxRecommendationsPageSize {
return recommendationsPage{}, NewClientError(400,
"invalid limit: must be an integer between 1 and "+strconv.Itoa(MaxRecommendationsPageSize))
}
page.limit = n
}
if hasCursor {
afterID, err := decodeRecommendationsCursor(rawCursor)
if err != nil {
return recommendationsPage{}, err
}
page.afterID = afterID
}
return page, nil
}

// encodeRecommendationsCursor turns the last row ID of a page into an opaque,
// URL-safe cursor. Recommendation IDs can carry characters such as "|", so
// the raw ID is not used directly in the query string.
func encodeRecommendationsCursor(id string) string {
return base64.RawURLEncoding.EncodeToString([]byte(id))
}

// decodeRecommendationsCursor reverses encodeRecommendationsCursor and
// rejects malformed or oversized cursors with a 400.
func decodeRecommendationsCursor(cursor string) (string, error) {
if len(cursor) > maxRecommendationsCursorLen {
return "", NewClientError(400, "invalid cursor: too long")
}
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil || len(raw) == 0 {
return "", NewClientError(400, "invalid cursor")
}
return string(raw), nil
}

// envelopeLen returns how many bytes v adds to the Lambda invoke response:
// its JSON encoding, re-encoded as the content of the response's body
// string (without the surrounding quotes).
func envelopeLen(v any) (int, error) {
raw, err := json.Marshal(v)
if err != nil {
return 0, err
}
quoted, err := json.Marshal(string(raw))
if err != nil {
return 0, err
}
return len(quoted) - 2, nil
}

// selectRecommendationsPage stamps and measures rows from sorted[start:] and
// returns how many fit in one page: at most limit rows, and no more bytes
// than budget once the fixed overhead (summary, regions, keys) and the next
// cursor are counted. It always takes at least one row when any remain, so a
// row that alone exceeds the budget cannot stall the cursor walk; that row
// is returned on its own page with a logged warning (it only fails at Lambda
// if it is over the 6 MiB hard cap by itself, which no page size could fix).
func selectRecommendationsPage(sorted []config.RecommendationRecord, start, limit, overhead, budget int) (int, error) {
used := overhead
n := 0
for i := start; i < len(sorted) && n < limit; i++ {
stampComputeCapacity(&sorted[i])
size, err := envelopeLen(&sorted[i])
if err != nil {
return 0, fmt.Errorf("failed to size recommendation %q: %w", sorted[i].ID, err)
}
// +1 for the separating comma; the cursor that would point at this
// row is reserved so next_cursor always fits.
cost := size + 1
cursorLen := base64.RawURLEncoding.EncodedLen(len(sorted[i].ID))
if n > 0 && used+cost+cursorLen > budget {
break
}
if n == 0 && used+cost+cursorLen > budget {
logging.Warnf("recommendation %q alone is %d bytes, over the %d-byte page budget; returning it on its own page", sorted[i].ID, size, budget)
}
used += cost
n++
}
return n, nil
}
Loading
Loading