Skip to content
Merged
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
2 changes: 1 addition & 1 deletion internal/purchase/azure_redis_routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func purchaseCacheRecThrough(t *testing.T, prov *MockProvider, providerName, reg
Recommendations: []config.RecommendationRecord{{ID: "rec-1", Provider: providerName, Service: "cache",
ResourceType: "cache.r6g.large", Region: region, Count: 1, Term: 1, Selected: true}}}
m := &Manager{config: store, email: sender, providerFactory: factory}
_, _, errs, err := m.processPurchaseRecommendations(ctx, exec, &config.PurchasePlan{Name: "p"}, "acct", (*provider.Config)(nil), "")
_, _, errs, _, err := m.processPurchaseRecommendations(ctx, exec, &config.PurchasePlan{Name: "p"}, "acct", (*provider.Config)(nil), "")
require.NoError(t, err)
require.Empty(t, errs)
require.True(t, exec.Recommendations[0].Purchased, "the purchase must reach the %s client", want)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func purchaseThroughPinnedAzureClient(t *testing.T, exec *config.PurchaseExecuti
prov.On("GetServiceClient", mock.Anything, common.ServiceSavingsPlansAll, mock.Anything).Return(provider.ServiceClient(client), nil)

m := &Manager{config: store, email: sender, providerFactory: factory}
_, _, errs, err := m.processPurchaseRecommendations(ctx, exec, &config.PurchasePlan{Name: "p"}, "acct", (*provider.Config)(nil), "")
_, _, errs, _, err := m.processPurchaseRecommendations(ctx, exec, &config.PurchasePlan{Name: "p"}, "acct", (*provider.Config)(nil), "")
require.NoError(t, err)
require.Empty(t, errs)
for i, rec := range exec.Recommendations {
Expand Down
27 changes: 16 additions & 11 deletions internal/purchase/execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,14 @@ func (m *Manager) executeSingleAccount(ctx context.Context, exec *config.Purchas
}
}

totalSavings, totalUpfront, purchaseErrors, procErr := m.processPurchaseRecommendations(ctx, exec, plan, accountID, provCfg, target.awsPartition)
totalSavings, totalUpfront, purchaseErrors, allRefused, procErr := m.processPurchaseRecommendations(ctx, exec, plan, accountID, provCfg, target.awsPartition)
if procErr != nil {
return procErr
}

if len(purchaseErrors) > 0 && !anyRecPurchased(exec.Recommendations) {
// Nothing committed — a clean total failure.
return fmt.Errorf("some purchases failed: %v", purchaseErrors)
return failedPurchasesError(purchaseErrors, allRefused)
}

// Confirm only when at least one rec committed (full or partial success).
Expand Down Expand Up @@ -373,7 +373,7 @@ func (m *Manager) executeForAccountLate(ctx context.Context, baseExec *config.Pu
}

accountID := account.ExternalID
totalSavings, totalUpfront, purchaseErrors, procErr := m.processPurchaseRecommendations(ctx, &acctExec, plan, accountID, provCfg, account.AWSPartition())
totalSavings, totalUpfront, purchaseErrors, allRefused, procErr := m.processPurchaseRecommendations(ctx, &acctExec, plan, accountID, provCfg, account.AWSPartition())
if procErr != nil {
acctExec.Status = "failed"
acctExec.Error = procErr.Error()
Expand All @@ -384,6 +384,7 @@ func (m *Manager) executeForAccountLate(ctx context.Context, baseExec *config.Pu
// Resolve the final status/error/completion stamp from the per-rec outcome
// (#642), and learn whether any rec committed (#1014).
_, committed = applyAccountOutcome(&acctExec, purchaseErrors)
markRefusedAccountRow(&acctExec, allRefused)

if saveErr := m.saveAccountRow(ctx, &acctExec); saveErr != nil {
return committed, fmt.Errorf("AUDIT LOSS: failed to save execution record for account %s: %w", account.ID, saveErr)
Expand Down Expand Up @@ -744,7 +745,7 @@ type recPurchaseOutcome struct {
index int
}

func (m *Manager) processPurchaseRecommendations(ctx context.Context, exec *config.PurchaseExecution, plan *config.PurchasePlan, accountID string, provCfg *provider.Config, awsPartition string) (totalSavings, totalUpfront float64, purchaseErrors []string, procErr error) {
func (m *Manager) processPurchaseRecommendations(ctx context.Context, exec *config.PurchaseExecution, plan *config.PurchasePlan, accountID string, provCfg *provider.Config, awsPartition string) (totalSavings, totalUpfront float64, purchaseErrors []string, allRefused bool, procErr error) {
// ExecutionID is carried into PurchaseOptions so executeSinglePurchase
// can tag every per-rec log line with the owning exec UUID. Without
// this, CloudWatch filtering by exec ID returns zero hits and a stuck
Expand All @@ -766,15 +767,15 @@ func (m *Manager) processPurchaseRecommendations(ctx context.Context, exec *conf
// settings (no-silent-fallbacks-on-money-paths rule).
globalCfg, err := m.config.GetGlobalConfig(ctx)
if err != nil {
return 0, 0, nil, fmt.Errorf("purchase[%s]: failed to load GlobalConfig for offering class: %w",
return 0, 0, nil, false, fmt.Errorf("purchase[%s]: failed to load GlobalConfig for offering class: %w",
exec.ExecutionID, err)
}
if globalCfg != nil {
opts.OfferingClass = globalCfg.OfferingClass
}
offeringClass, err := resolveEC2OfferingClass(opts.OfferingClass)
if err != nil {
return 0, 0, nil, fmt.Errorf("purchase[%s]: %w", exec.ExecutionID, err)
return 0, 0, nil, false, fmt.Errorf("purchase[%s]: %w", exec.ExecutionID, err)
}

// Build the list of selected indices once so the fan-out closure only
Expand All @@ -783,7 +784,7 @@ func (m *Manager) processPurchaseRecommendations(ctx context.Context, exec *conf
if len(selected) == 0 {
logging.Infof("purchase[%s]: no selected recommendations, nothing to execute (account=%s plan=%q)",
exec.ExecutionID, accountID, plan.Name)
return 0, 0, nil, nil
return 0, 0, nil, false, nil
}
logging.Infof("purchase[%s]: dispatching %d recommendation(s) for account=%s plan=%q",
exec.ExecutionID, len(selected), accountID, plan.Name)
Expand Down Expand Up @@ -841,8 +842,8 @@ func (m *Manager) processPurchaseRecommendations(ctx context.Context, exec *conf
// there are no concurrent writes to totals, exec.Recommendations, or
// purchaseErrors (05-N2). Do NOT move the aggregation inside the FanOut
// closure or run it concurrently with the fan-out.
totalSavings, totalUpfront, purchaseErrors = m.aggregatePurchaseOutcomes(ctx, exec, plan, accountID, offeringClass, results)
return totalSavings, totalUpfront, purchaseErrors, nil
totalSavings, totalUpfront, purchaseErrors, allRefused = m.aggregatePurchaseOutcomes(ctx, exec, plan, accountID, offeringClass, results)
return totalSavings, totalUpfront, purchaseErrors, allRefused, nil
}

// aggregatePurchaseOutcomes walks the fan-out results serially and writes
Expand All @@ -851,16 +852,18 @@ func (m *Manager) processPurchaseRecommendations(ctx context.Context, exec *conf
// so the aggregation logic is single-threaded — no concurrent writes to
// totals, purchaseErrors, or exec.Recommendations[i] regardless of how
// many recs ran in parallel.
func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.PurchaseExecution, plan *config.PurchasePlan, accountID string, offeringClass ec2types.OfferingClassType, results []execution.Result[recPurchaseOutcome]) (float64, float64, []string) { //nolint:gocritic // unnamedResult: return names would conflict with body locals
func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.PurchaseExecution, plan *config.PurchasePlan, accountID string, offeringClass ec2types.OfferingClassType, results []execution.Result[recPurchaseOutcome]) (float64, float64, []string, bool) { //nolint:gocritic // unnamedResult: return names would conflict with body locals
var totalSavings, totalUpfront float64
var purchaseErrors []string
var tally failureTally
for _, r := range results {
// Closure-level error (parse / bounds / framework). r.Value is the
// zero recPurchaseOutcome here — its index is 0 and would mis-target
// exec.Recommendations[0] if blindly trusted. Record it as a
// generic execution failure and skip the per-rec writes.
if r.Err != nil {
logging.Errorf("Internal fan-out failure during purchase execution: %v", r.Err)
tally.add(r.Err)
purchaseErrors = append(purchaseErrors, fmt.Sprintf("internal fan-out error: %v", r.Err))
continue
}
Expand All @@ -872,6 +875,7 @@ func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.Pu
// this otherwise).
if i < 0 || i >= len(exec.Recommendations) {
logging.Errorf("Aggregator received out-of-range index %d (len=%d)", i, len(exec.Recommendations))
tally.add(nil)
purchaseErrors = append(purchaseErrors, fmt.Sprintf("aggregator index %d out of range", i))
continue
}
Expand All @@ -886,6 +890,7 @@ func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.Pu
if v.purchase.CommitmentID != "" {
exec.Recommendations[i].PurchaseID = v.purchase.CommitmentID
}
tally.add(v.err)
purchaseErrors = append(purchaseErrors, fmt.Sprintf("%s: %v", rec.ResourceType, v.err))
continue
}
Expand All @@ -904,7 +909,7 @@ func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.Pu
recordHistoryAuditGap(exec, v.purchase.CommitmentID, histErr)
}
}
return totalSavings, totalUpfront, purchaseErrors
return totalSavings, totalUpfront, purchaseErrors, tally.allRefused(exec.Recommendations)
}

// recordHistoryAuditGap stamps exec.Error with a note that a successful
Expand Down
2 changes: 1 addition & 1 deletion internal/purchase/execution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2250,7 +2250,7 @@ func TestProcessPurchaseRecommendations_GlobalConfigError_FailsInsteadOfDefaulti
dashboardURL: "https://dashboard.example.com",
}

_, _, purchaseErrors, procErr := manager.processPurchaseRecommendations(ctx, exec, plan, "111111111111", nil, "")
_, _, purchaseErrors, _, procErr := manager.processPurchaseRecommendations(ctx, exec, plan, "111111111111", nil, "")

// The function must return an error derived from the DB failure.
require.Error(t, procErr, "GlobalConfig load failure must abort processing, not silently default to convertible")
Expand Down
6 changes: 3 additions & 3 deletions internal/purchase/money_path_regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func captureIdempotencyTokens(t *testing.T, exec *config.PurchaseExecution) map[

// provCfg is nil: the mock factory matches mock.Anything for it and the
// real factory is never reached.
_, _, errs, procErr := manager.processPurchaseRecommendations(ctx, exec, plan, "111111111111", nil, "")
_, _, errs, _, procErr := manager.processPurchaseRecommendations(ctx, exec, plan, "111111111111", nil, "")
require.NoError(t, procErr, "processPurchaseRecommendations must not fail in the happy path")
require.Empty(t, errs, "all recs must commit so the captured token reflects a real purchase")
return tokens
Expand Down Expand Up @@ -134,7 +134,7 @@ func TestOfferingClassStampedOnHistoryMatchesPurchase(t *testing.T) {
},
}

_, _, errs, procErr := manager.processPurchaseRecommendations(ctx, exec, &config.PurchasePlan{Name: "Direct purchase"}, "111111111111", nil, "")
_, _, errs, _, procErr := manager.processPurchaseRecommendations(ctx, exec, &config.PurchasePlan{Name: "Direct purchase"}, "111111111111", nil, "")
require.NoError(t, procErr)
require.Empty(t, errs)
require.NotNil(t, history)
Expand Down Expand Up @@ -162,7 +162,7 @@ func TestUnknownOfferingClassRefusesBeforePurchase(t *testing.T) {
},
}

_, _, errs, procErr := manager.processPurchaseRecommendations(ctx, exec, &config.PurchasePlan{Name: "Direct purchase"}, "111111111111", nil, "")
_, _, errs, _, procErr := manager.processPurchaseRecommendations(ctx, exec, &config.PurchasePlan{Name: "Direct purchase"}, "111111111111", nil, "")
require.ErrorIs(t, procErr, errUnknownOfferingClass)
assert.Nil(t, errs)
mockFactory.AssertNotCalled(t, "CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything)
Expand Down
2 changes: 1 addition & 1 deletion internal/purchase/plan_step_proofs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func tokenCapture(t *testing.T, providerName string, exec *config.PurchaseExecut
mu.Unlock()
}).Return(common.PurchaseResult{Success: true, CommitmentID: "c"}, nil)
m := &Manager{config: store, email: sender, providerFactory: factory}
_, _, errs, err := m.processPurchaseRecommendations(ctx, exec, &config.PurchasePlan{Name: "p"}, "acct", (*provider.Config)(nil), "")
_, _, errs, _, err := m.processPurchaseRecommendations(ctx, exec, &config.PurchasePlan{Name: "p"}, "acct", (*provider.Config)(nil), "")
require.NoError(t, err)
require.Empty(t, errs)
return tokens
Expand Down
53 changes: 53 additions & 0 deletions internal/purchase/refusal_tally.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package purchase

import (
"errors"
"fmt"

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

// failureTally counts one aggregation's failed recs and how many of them were
// refused before any purchase call. Per-rec errors reach the callers only as
// strings, so the beforeSubmission class has to be read here, while the error
// values are still in hand.
type failureTally struct{ failed, refused int }

// add records one failed rec. A rec counts as refused only when its error
// carries the beforeSubmission class and no unknown outcome: an error that
// also wraps ErrOutcomeUnknown may have reached the cloud.
func (t *failureTally) add(err error) {
t.failed++
var notSubmitted *beforeSubmissionError
if errors.As(err, &notSubmitted) && !errors.Is(err, common.ErrOutcomeUnknown) {
t.refused++
}
}

// allRefused reports that every failed rec was refused before submission and
// no rec holds a commitment, so the whole row proves nothing was submitted.
func (t failureTally) allRefused(recs []config.RecommendationRecord) bool {
return t.failed > 0 && t.failed == t.refused && !anyRecPurchased(recs)
}

// failedPurchasesError is the "some purchases failed" error of a run where
// nothing committed. When every failure was a pre-submission refusal it carries
// the beforeSubmission class, so saveTerminalExecution records no_submission
// and releases the row's suppressions.
func failedPurchasesError(purchaseErrors []string, allRefused bool) error {
err := fmt.Errorf("some purchases failed: %v", purchaseErrors)
if allRefused {
return beforeSubmission(err)
}
return err
}

// markRefusedAccountRow stamps NoSubmission on a failed per-account row whose
// every rec was refused before submission. The fan-out saves that row itself,
// bypassing saveTerminalExecution.
func markRefusedAccountRow(row *config.PurchaseExecution, allRefused bool) {
if allRefused && row.Status == config.StatusFailed {
row.NoSubmission = true
}
}
Loading
Loading