diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index dd7560f3f..a9e6f9324 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -1875,6 +1875,28 @@ describe('Recommendations Module', () => { await pendingRefresh; }); + test('warns how many GCP recommendations the last collection skipped (#1472)', async () => { + mockGetRecs(); + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: new Date().toISOString(), + last_collection_error: null, + last_collection_partial: { + providers: [{ + provider: 'gcp', incomplete_accounts: 0, attempted_scopes: 0, failed_scopes: 0, + failed_details: 0, skipped_recommendations: 3, reasons: {}, + }], + }, + }); + + await loadRecommendations(); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'warning', message: expect.stringContaining('3 GCP recommendation(s) skipped') }), + ); + }); + test('persistent in-flight toast — shown with timeout: null so it stays until settled', async () => { mockGetRecs(); (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ diff --git a/frontend/src/api/recommendations.ts b/frontend/src/api/recommendations.ts index bc9aba568..810c48056 100644 --- a/frontend/src/api/recommendations.ts +++ b/frontend/src/api/recommendations.ts @@ -75,6 +75,18 @@ export async function refreshRecommendations(): Promise; +} + export interface RecommendationsFreshness { last_collected_at: string | null; last_collection_error: string | null; @@ -84,6 +96,7 @@ export interface RecommendationsFreshness { // tolerate `undefined` for legacy callsites that haven't been updated // and treat undefined the same as null. last_collection_started_at?: string | null; + last_collection_partial?: { providers: PartialProviderSweep[] } | null; } /** diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 1a3daa4c8..53a8b2622 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -321,6 +321,19 @@ export function setupRecommendationsHandlers(): void { state.subscribeAccount(scheduleReload); } +/** + * Names the recommendations a provider dropped as invalid in the last + * collection (issue #1472), or null when none were. The rest of that + * provider's recommendations are shown normally. + */ +export function describeSkippedRecommendations(f: RecommendationsFreshness): string | null { + const parts = (f.last_collection_partial?.providers ?? []) + .filter((p) => (p.skipped_recommendations ?? 0) > 0) + .map((p) => `${p.skipped_recommendations} ${p.provider.toUpperCase()}`); + if (parts.length === 0) return null; + return `${parts.join(', ')} recommendation(s) skipped as invalid (for example a non-USD price or an unrecognized term) in the last collection. The others are shown.`; +} + /** * Check freshness and fire a background refresh if the cache is stale or cold. * Fire-and-forget: does not block the table render that already happened. @@ -357,6 +370,9 @@ export async function triggerAutoRefreshIfStale( return; } + const skipNotice = describeSkippedRecommendations(freshness); + if (skipNotice) showToast({ message: skipNotice, kind: 'warning' }); + const lastCollectedMs = freshness.last_collected_at === null ? null diff --git a/go.mod b/go.mod index 22a35b612..5fb3a2f61 100644 --- a/go.mod +++ b/go.mod @@ -81,10 +81,10 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 - github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026094505-9cec521d6531 - github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026094505-9cec521d6531 - github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026094505-9cec521d6531 - github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026094505-9cec521d6531 + github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026123645-88b95b64b043 + github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026123645-88b95b64b043 + github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026123645-88b95b64b043 + github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026123645-88b95b64b043 github.com/aws/aws-lambda-go v1.55.1 github.com/aws/aws-sdk-go-v2/service/kms v1.61.4 github.com/aws/aws-sdk-go-v2/service/lambda v1.113.0 diff --git a/go.sum b/go.sum index 35075673e..06deac0b9 100644 --- a/go.sum +++ b/go.sum @@ -92,14 +92,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026094505-9cec521d6531 h1:x7bdeCDu+GdtVQ5Mcx8j0u09qiCssTPKtjru5nVAzQ0= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026094505-9cec521d6531/go.mod h1:rEJErEsL40Np/ttf0RDoNNC2XQsN1GmWyFf34y0JLD8= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026094505-9cec521d6531 h1:aZhRGSwaGxZ3gnPuR9iqHo069z0o0rJSoA7cgs9xqbg= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026094505-9cec521d6531/go.mod h1:yVHEL5ceNS/CsDatzLH0+Qmwtoi7yY4tIHvqyvhdtig= -github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026094505-9cec521d6531 h1:cWndlu++2ZWeSxvZthojAsTEHOdGU7BlIufJ60ssfAY= -github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026094505-9cec521d6531/go.mod h1:zA51HKXtBvHHMYd+owRDmn4p8ialZrLFzI9I052uUmo= -github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026094505-9cec521d6531 h1:Y7mztemGOd3kChFN236+zG4XLQ72E/MNiEdh+SU3Sv8= -github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026094505-9cec521d6531/go.mod h1:/HxBqXBb5v69+EHLsibHnekkEYRP8TvyCmT6d9QRxt0= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026123645-88b95b64b043 h1:aNxMUAEefkT973eGTUbVppd1X99zLKWRUYmJWfO1BUI= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261026123645-88b95b64b043/go.mod h1:rEJErEsL40Np/ttf0RDoNNC2XQsN1GmWyFf34y0JLD8= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026123645-88b95b64b043 h1:UjP0mxD+iv/ZIZg6hIm6dWHILzNX+0T/WCgiDUk5c+8= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261026123645-88b95b64b043/go.mod h1:yVHEL5ceNS/CsDatzLH0+Qmwtoi7yY4tIHvqyvhdtig= +github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026123645-88b95b64b043 h1:uhHCm5B8G6sGGoRCOyUp41tbza4W3pkh2EeUwT8UVKo= +github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261026123645-88b95b64b043/go.mod h1:zA51HKXtBvHHMYd+owRDmn4p8ialZrLFzI9I052uUmo= +github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026123645-88b95b64b043 h1:ryqPNyihinNu9065Ysi4c9sWP90yX9jeBP7fDOrO6jw= +github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261026123645-88b95b64b043/go.mod h1:/HxBqXBb5v69+EHLsibHnekkEYRP8TvyCmT6d9QRxt0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= diff --git a/internal/api/handler_recommendations_test.go b/internal/api/handler_recommendations_test.go index e19679a6a..776e37227 100644 --- a/internal/api/handler_recommendations_test.go +++ b/internal/api/handler_recommendations_test.go @@ -152,6 +152,11 @@ func TestHandler_getRecommendationsFreshness(t *testing.T) { AttemptedScopes: 10, FailedScopes: 9, Reasons: map[string]int{config.PartialReasonThrottled: 9}, + }, { + // GCP skipped recommendations (issue #1472): complete sweep, N dropped. + Provider: "gcp", + SkippedRecommendations: 3, + Reasons: map[string]int{}, }}, } mockStore.On("GetRecommendationsFreshness", ctx). @@ -167,7 +172,9 @@ func TestHandler_getRecommendationsFreshness(t *testing.T) { body, err := json.Marshal(got) require.NoError(t, err) assert.JSONEq(t, `{"providers":[{"provider":"azure","incomplete_accounts":1,"attempted_scopes":10,`+ - `"failed_scopes":9,"failed_details":0,"reasons":{"throttled":9}}]}`, + `"failed_scopes":9,"failed_details":0,"skipped_recommendations":0,"reasons":{"throttled":9}},`+ + `{"provider":"gcp","incomplete_accounts":0,"attempted_scopes":0,"failed_scopes":0,"failed_details":0,`+ + `"skipped_recommendations":3,"reasons":{}}]}`, string(mustField(t, body, "last_collection_partial"))) }) diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 0280ff99c..63d727194 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -6806,7 +6806,7 @@ components: PartialProviderSweep: type: object - required: [provider, incomplete_accounts, attempted_scopes, failed_scopes, failed_details, reasons] + required: [provider, incomplete_accounts, attempted_scopes, failed_scopes, failed_details, skipped_recommendations, reasons] properties: provider: type: string @@ -6825,6 +6825,12 @@ components: failed_details: type: integer description: Individual recommendation details rejected inside answering scopes (AWS). + skipped_recommendations: + type: integer + description: > + Recommendations the provider dropped as invalid (GCP: non-USD, + malformed amount or plan, unrecognized term) while returning the + rest. Their accounts' sweeps were complete. reasons: type: object description: > diff --git a/internal/config/types.go b/internal/config/types.go index 71f3c73c2..7041f89be 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -956,6 +956,10 @@ type PartialProviderSweep struct { // FailedDetails is how many individual recommendation details were // rejected inside scopes that otherwise answered (AWS only). FailedDetails int `json:"failed_details"` + // SkippedRecommendations is how many recommendations the provider + // dropped as invalid (GCP: non-USD, malformed amount or plan, unknown + // term) while returning the rest. Those accounts' sweeps were complete. + SkippedRecommendations int `json:"skipped_recommendations"` // Reasons counts the failure causes by PartialReason* category. Reasons map[string]int `json:"reasons"` } diff --git a/internal/scheduler/partial_skip_test.go b/internal/scheduler/partial_skip_test.go new file mode 100644 index 000000000..ad947df78 --- /dev/null +++ b/internal/scheduler/partial_skip_test.go @@ -0,0 +1,91 @@ +package scheduler + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// skipErr stands in for the GCP provider's unexported partial-result error: +// the scheduler must match the Partial() method, not the type (issue #1472). +type skipErr struct { + msg string + partial bool +} + +func (e *skipErr) Error() string { return e.msg } +func (e *skipErr) Partial() bool { return e.partial } + +const skipMsg = "gcp recommendations: 3 recommendation(s) skipped: computeengine us-east1: non-USD; cloudsql us-east1: bad plan" + +func gcpAmbient(s *Scheduler) ([]config.RecommendationRecord, []string, *sweepGap, error) { + return s.collectGCPRecommendations(context.Background(), &config.GlobalConfig{}) +} + +func TestGCPPartialResult_KeepsRecsAndRecordsSkips(t *testing.T) { + t.Setenv("GCP_PROJECT_ID", "proj-a") + s, _ := newPartialSweepScheduler(t, "gcp", fmt.Errorf("collect: %w", &skipErr{msg: skipMsg, partial: true})) + + recs, roster, gap, err := gcpAmbient(s) + + require.NoError(t, err, "a partial GCP result must not fail the collection run") + assert.NotEmpty(t, recs, "the recommendations that converted must be kept") + require.NotNil(t, gap, "the skip must be recorded for the user") + assert.Equal(t, 3, gap.skippedRecommendations) + assert.Equal(t, 3, gap.toProviderSweep("gcp").SkippedRecommendations) + assert.NotEmpty(t, roster, + "skipped recommendations are invalid, so the account stays eviction-eligible and their stale rows go") + assert.Zero(t, gap.forAccount().incompleteAccounts, "a skip is not an incomplete account") +} + +func TestGCPPartialResult_NonPartialErrorsStayHard(t *testing.T) { + for name, err := range map[string]error{ + "plain error": errors.New("boom"), + "Partial() == false": &skipErr{msg: skipMsg, partial: false}, + } { + t.Run(name, func(t *testing.T) { + t.Setenv("GCP_PROJECT_ID", "proj-a") + s, _ := newPartialSweepScheduler(t, "gcp", err) + + recs, roster, gap, gotErr := gcpAmbient(s) + + require.Error(t, gotErr, "a non-partial error must fail the run") + assert.Empty(t, recs) + assert.Empty(t, roster) + assert.Nil(t, gap) + }) + } +} + +func TestGCPPartialResult_FanOutKeepsAccountEvictable(t *testing.T) { + accounts := []config.CloudAccount{{ID: "a1", Name: "a1", ExternalID: "p1"}, {ID: "a2", Name: "a2", ExternalID: "p2"}} + fn := func(_ context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, *sweepGap, error) { + gap, err := tolerateIncompleteSweep("gcp", &skipErr{msg: skipMsg, partial: true}) + if acct.ID == "a2" { + // A lost project in the same account hold back eviction as before. + gap = mergeSweepGaps(gap, gapFromPartialSubscriptions(partialSweepErr("p-x"))) + } + return []config.RecommendationRecord{{ID: acct.ID}}, gap, err + } + + recs, outcome := fanOutPerAccount(context.Background(), "GCP", accounts, fn) + + assert.Len(t, recs, 2) + assert.Equal(t, []string{"a1"}, outcome.SucceededAccountIDs) + assert.Equal(t, []string{"a2"}, outcome.IncompleteAccountIDs) + require.NotNil(t, outcome.Gap) + assert.Equal(t, 6, outcome.Gap.skippedRecommendations) + assert.Equal(t, 1, outcome.Gap.incompleteAccounts) +} + +func TestGCPPartialResult_UnparsableCountStillRecorded(t *testing.T) { + gap, err := tolerateIncompleteSweep("gcp", &skipErr{msg: "something skipped", partial: true}) + require.NoError(t, err) + require.NotNil(t, gap) + assert.Zero(t, gap.skippedRecommendations) +} diff --git a/internal/scheduler/partial_sweep.go b/internal/scheduler/partial_sweep.go index 516a16217..43cfcfd2b 100644 --- a/internal/scheduler/partial_sweep.go +++ b/internal/scheduler/partial_sweep.go @@ -4,9 +4,12 @@ import ( "context" "errors" "net/http" + "regexp" + "strconv" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" awsrecommendations "github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations" azureprovider "github.com/LeanerCloud/cloud-commitments-go/providers/azure" gcpprovider "github.com/LeanerCloud/cloud-commitments-go/providers/gcp" @@ -30,7 +33,50 @@ type sweepGap struct { attemptedScopes int failedScopes int failedDetails int - reasons map[string]int + // skippedRecommendations counts recommendations the provider dropped as + // invalid (for example non-USD) while returning the rest. A gap made only + // of these does not hold back stale-row eviction; see holdsEviction. + skippedRecommendations int + // skippedOnly is true when every sweep merged into this gap lost nothing + // but invalid recommendations, so the account's sweep itself was complete. + skippedOnly bool + reasons map[string]int +} + +// holdsEviction reports whether the sweep must stay out of the succeeded- +// account roster. A nil gap (complete sweep) and a skipped-only gap do not: +// a skipped recommendation is invalid, so its previous-cycle row is exactly +// what stale-row eviction should remove rather than keep offering for +// purchase. Every other gap lost whole scopes, whose rows must survive. +func (g *sweepGap) holdsEviction() bool { + return g != nil && !g.skippedOnly +} + +var skippedCountRE = regexp.MustCompile(`(\d+) recommendation\(s\) skipped`) + +// partialResult is implemented by provider errors that accompany usable +// results (GCP's skipped recommendations). Matching the method, not the +// provider's internal type, keeps this provider-agnostic. +type partialResult interface{ Partial() bool } + +// tolerateSkippedRecommendations keeps the recommendations returned alongside +// a Partial() error and records how many were skipped. Any other error is +// returned unchanged. +func tolerateSkippedRecommendations(providerName string, err error) (*sweepGap, error) { + var p partialResult + if err == nil || !errors.As(err, &p) || !p.Partial() { + return nil, err + } + logging.Warnf("%s recommendations partial, keeping the rest: %v", providerName, err) + // The count is parsed from the message because the contract exposes only + // Partial(); an unparsable message still yields a gap, with count 0. + n := 0 + if m := skippedCountRE.FindStringSubmatch(err.Error()); m != nil { + if v, convErr := strconv.Atoi(m[1]); convErr == nil { + n = v + } + } + return &sweepGap{skippedRecommendations: n, skippedOnly: true, reasons: map[string]int{}}, nil } // gapFromAWSIncomplete summarizes an AWS IncompleteRecommendationsError. @@ -70,7 +116,9 @@ func (g *sweepGap) forAccount() *sweepGap { return nil } out := mergeSweepGaps(nil, g) - out.incompleteAccounts = 1 + if g.holdsEviction() { + out.incompleteAccounts = 1 + } return out } @@ -80,11 +128,13 @@ func mergeSweepGaps(a, b *sweepGap) *sweepGap { if a == nil && b == nil { return nil } - out := &sweepGap{reasons: map[string]int{}} + out := &sweepGap{reasons: map[string]int{}, skippedOnly: true} for _, g := range []*sweepGap{a, b} { if g == nil { continue } + out.skippedOnly = out.skippedOnly && g.skippedOnly + out.skippedRecommendations += g.skippedRecommendations out.incompleteAccounts += g.incompleteAccounts out.attemptedScopes += g.attemptedScopes out.failedScopes += g.failedScopes @@ -103,12 +153,13 @@ func (g *sweepGap) toProviderSweep(providerName string) config.PartialProviderSw reasons[reason] = n } return config.PartialProviderSweep{ - Provider: providerName, - IncompleteAccounts: g.incompleteAccounts, - AttemptedScopes: g.attemptedScopes, - FailedScopes: g.failedScopes, - FailedDetails: g.failedDetails, - Reasons: reasons, + Provider: providerName, + IncompleteAccounts: g.incompleteAccounts, + AttemptedScopes: g.attemptedScopes, + FailedScopes: g.failedScopes, + FailedDetails: g.failedDetails, + SkippedRecommendations: g.skippedRecommendations, + Reasons: reasons, } } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 35848f40c..b756dd37b 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -547,9 +547,9 @@ func (s *Scheduler) collectAWSRecommendations(ctx context.Context, globalCfg *co // in those cases. if acctID := s.resolveAmbientHostAccountID(ctx); acctID != "" { recs = s.tagAccount(recs, acctID) - return recs, ambientEvictionRoster(gap == nil, acctID), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), acctID), gap.forAccount(), nil } - return recs, ambientEvictionRoster(gap == nil, ""), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), ""), gap.forAccount(), nil } recs, outcome := fanOutPerAccount(ctx, "AWS", accounts, func(ctx context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, *sweepGap, error) { @@ -653,12 +653,12 @@ func fanOutPerAccount( // SucceededAccountIDs, the roster that authorizes stale-row // eviction. See tolerateIncompleteSweep for why (issue #1652). outcome.SucceededCount++ - if gap == nil { - outcome.SucceededAccountIDs = append(outcome.SucceededAccountIDs, acct.ID) - } else { + if gap.holdsEviction() { outcome.IncompleteAccountIDs = append(outcome.IncompleteAccountIDs, acct.ID) - outcome.Gap = mergeSweepGaps(outcome.Gap, gap.forAccount()) + } else { + outcome.SucceededAccountIDs = append(outcome.SucceededAccountIDs, acct.ID) } + outcome.Gap = mergeSweepGaps(outcome.Gap, gap.forAccount()) mu.Unlock() return nil }) @@ -886,9 +886,9 @@ func (s *Scheduler) collectAzureRecommendations(ctx context.Context, _ *config.G } if acctID := s.resolveAmbientAccountID(ctx, "azure", subscriptionID); acctID != "" { recs = s.tagAccount(recs, acctID) - return recs, ambientEvictionRoster(gap == nil, acctID), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), acctID), gap.forAccount(), nil } - return recs, ambientEvictionRoster(gap == nil, ""), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), ""), gap.forAccount(), nil } recs, outcome := fanOutPerAccount(ctx, "Azure", accounts, s.collectAzureForAccount) @@ -961,9 +961,9 @@ func (s *Scheduler) collectGCPRecommendations(ctx context.Context, _ *config.Glo } if acctID := s.resolveAmbientAccountID(ctx, "gcp", projectID); acctID != "" { recs = s.tagAccount(recs, acctID) - return recs, ambientEvictionRoster(gap == nil, acctID), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), acctID), gap.forAccount(), nil } - return recs, ambientEvictionRoster(gap == nil, ""), gap.forAccount(), nil + return recs, ambientEvictionRoster(!gap.holdsEviction(), ""), gap.forAccount(), nil } recs, outcome := fanOutPerAccount(ctx, "GCP", accounts, s.collectGCPForAccount) @@ -1080,7 +1080,7 @@ func tolerateIncompleteSweep(providerName string, err error) (*sweepGap, error) } partial := azureprovider.AsPartialSubscriptionFailure(err) if partial == nil { - return nil, err + return tolerateSkippedRecommendations(providerName, err) } logging.Warnf( "%s recommendations incomplete: %d of %d subscriptions succeeded; not queried: %s (keeping the %d that did, "+