diff --git a/internal/api/handler_ladder_reconcile_retry_integration_test.go b/internal/api/handler_ladder_reconcile_retry_integration_test.go index 93895076..881c2e72 100644 --- a/internal/api/handler_ladder_reconcile_retry_integration_test.go +++ b/internal/api/handler_ladder_reconcile_retry_integration_test.go @@ -70,9 +70,10 @@ func newLadderRetryRig(t *testing.T) *ladderRetryRig { // tranche id as its idempotency key, the link Retry uses to find the tranche. func (r *ladderRetryRig) addFiredTranche(t *testing.T) { ctx := context.Background() + basis, rate := config.LadderAmountCommitment, "1" // only commitment-dollar rows are claimable tr := config.LadderTrancheDB{ID: uuid.New().String(), ConfigID: &r.cfgID, LayerType: ladder.LayerComputeSP, Term: ladder.Term1Year, PaymentOption: ladder.PaymentNoUpfront, Status: ladder.TrancheStatusScheduled, - AmountUSDHr: 5, ScheduledDate: time.Now().Add(-time.Hour)} + AmountUSDHr: 5, ScheduledDate: time.Now().Add(-time.Hour), AmountBasis: &basis, CommitmentRate: &rate} require.NoError(t, r.store.SaveLadderTranches(ctx, []config.LadderTrancheDB{tr})) var failedID string _, err := r.store.ClaimDueLadderTranches(ctx, r.cfgID, time.Now(), 1, diff --git a/internal/config/ladder_timeline.go b/internal/config/ladder_timeline.go index 8ec990ac..7b757617 100644 --- a/internal/config/ladder_timeline.go +++ b/internal/config/ladder_timeline.go @@ -53,7 +53,11 @@ type LadderTimelineRun struct { ID string `json:"id"` ConfigID string `json:"config_id"` TotalHourlyCommit string `json:"total_hourly_commit"` - Status ladder.RunStatus `json:"status"` + // ReasonCode is the plan's reason_code, set only when the run could not + // size its gap (for example "missing_discount_rate"). An ordinary run, + // including a target-met Hold, has none. + ReasonCode string `json:"reason_code,omitempty"` + Status ladder.RunStatus `json:"status"` } type LadderTimelineAction struct { diff --git a/internal/config/store_postgres_ladder_amend_ceiling_integration_test.go b/internal/config/store_postgres_ladder_amend_ceiling_integration_test.go index b6134dde..4b841e4e 100644 --- a/internal/config/store_postgres_ladder_amend_ceiling_integration_test.go +++ b/internal/config/store_postgres_ladder_amend_ceiling_integration_test.go @@ -257,6 +257,9 @@ func TestLadderAmendCeiling_SettledSkewMargin(t *testing.T) { func TestLadderAmendCeiling_CancelsOverdueBeforeCounting(t *testing.T) { f := newCeilingFixture(t) _, ids := f.plan(fptr(10), fptr(0), fptr(100), 5, 5) + // Commitment basis at rate 1 (same netting) so only the cancel, not the + // claim's basis filter, keeps X from being claimed below. + f.setBasis(ids[0], "commitment", "1") // X is past the 24h grace window: neither counted in flight nor claimable at "now". _, err := f.store.db.Exec(f.ctx, "UPDATE ladder_tranches SET scheduled_date=now()-interval '25 hours' WHERE id=$1", ids[0]) require.NoError(t, err) diff --git a/internal/config/store_postgres_ladder_lifecycle.go b/internal/config/store_postgres_ladder_lifecycle.go index 3c4bf1d2..7fd70364 100644 --- a/internal/config/store_postgres_ladder_lifecycle.go +++ b/internal/config/store_postgres_ladder_lifecycle.go @@ -100,6 +100,13 @@ func (s *PostgresStore) TransitionLadderTrancheTx(ctx context.Context, tx pgx.Tx // than the planner's, a claim could otherwise fire a row the planner's netting // read had already excluded as expired, and the row would be bought AND // re-planned. A callback slower than ladderLockTimeout makes planners fail loudly. +// +// Only rows stored in commitment dollars (amount_basis = 'commitment', which +// the table constraint pairs with a commitment_rate) are claimable: a buyer +// spends amount_usd_hr as commitment dollars, and a legacy NULL-basis or +// on_demand_equivalent row would over-buy by 1/rate (#946). Those rows stay +// netted until the overdue sweep cancels them, and the next run re-plans +// their gap in commitment dollars. func (s *PostgresStore) ClaimDueLadderTranches( ctx context.Context, configID string, @@ -150,6 +157,7 @@ func selectDueLadderTranchesTx(ctx context.Context, tx pgx.Tx, configID string, WHERE config_id = $1 AND status = 'scheduled' AND execution_id IS NULL + AND amount_basis = 'commitment' AND scheduled_date <= $2 AND scheduled_date > $3 ORDER BY scheduled_date, id diff --git a/internal/config/store_postgres_ladder_lifecycle_integration_test.go b/internal/config/store_postgres_ladder_lifecycle_integration_test.go index 2d74ba02..ae237fff 100644 --- a/internal/config/store_postgres_ladder_lifecycle_integration_test.go +++ b/internal/config/store_postgres_ladder_lifecycle_integration_test.go @@ -34,6 +34,16 @@ func lifecycleTranche(cfgID string, amount float64, status ladder.TrancheStatus, return tr } +// claimableTranche is a scheduled lifecycleTranche planned in commitment +// dollars, the only basis ClaimDueLadderTranches fires (#946). Rate 1 keeps +// its on-demand-equivalent netting equal to its amount. +func claimableTranche(cfgID string, amount float64, at time.Time) LadderTrancheDB { + tr := lifecycleTranche(cfgID, amount, ladder.TrancheStatusScheduled, at) + basis, rate := LadderAmountCommitment, "1" + tr.AmountBasis, tr.CommitmentRate = &basis, &rate + return tr +} + func trancheStatus(ctx context.Context, t *testing.T, store *PostgresStore, id string) ladder.TrancheStatus { t.Helper() var st string @@ -134,11 +144,11 @@ func TestClaimDueLadderTranches_ClaimsOnlyDueUnexpiredAndKeepsNetting(t *testing var calls atomic.Int64 create := newExecCreator(ctx, t, store, &calls) - due := lifecycleTranche(cfgID, 1, ladder.TrancheStatusScheduled, asOf.Add(-time.Hour)) - dueNow := lifecycleTranche(cfgID, 2, ladder.TrancheStatusScheduled, asOf) - future := lifecycleTranche(cfgID, 4, ladder.TrancheStatusScheduled, asOf.Add(time.Microsecond)) - expired := lifecycleTranche(cfgID, 8, ladder.TrancheStatusScheduled, asOf.Add(-LadderFireGrace)) - otherCfg := lifecycleTranche(otherID, 16, ladder.TrancheStatusScheduled, asOf.Add(-time.Hour)) + due := claimableTranche(cfgID, 1, asOf.Add(-time.Hour)) + dueNow := claimableTranche(cfgID, 2, asOf) + future := claimableTranche(cfgID, 4, asOf.Add(time.Microsecond)) + expired := claimableTranche(cfgID, 8, asOf.Add(-LadderFireGrace)) + otherCfg := claimableTranche(otherID, 16, asOf.Add(-time.Hour)) require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{due, dueNow, future, expired, otherCfg})) before := inFlight(ctx, t, store, cfgID, asOf) assert.Equal(t, 1.0+2+4, before, "expired row is not netted") @@ -163,8 +173,8 @@ func TestClaimDueLadderTranches_ClaimsOnlyDueUnexpiredAndKeepsNetting(t *testing require.NoError(t, err) assert.Empty(t, again, "a second claim finds nothing: idempotent") - limited := lifecycleTranche(cfgID, 32, ladder.TrancheStatusScheduled, asOf.Add(-time.Minute)) - limited2 := lifecycleTranche(cfgID, 64, ladder.TrancheStatusScheduled, asOf.Add(-2*time.Minute)) + limited := claimableTranche(cfgID, 32, asOf.Add(-time.Minute)) + limited2 := claimableTranche(cfgID, 64, asOf.Add(-2*time.Minute)) require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{limited, limited2})) one, err := store.ClaimDueLadderTranches(ctx, cfgID, asOf, 1, create) require.NoError(t, err) @@ -175,6 +185,50 @@ func TestClaimDueLadderTranches_ClaimsOnlyDueUnexpiredAndKeepsNetting(t *testing require.Error(t, err) } +// Legacy rows (NULL basis from before migration 000121, or +// on_demand_equivalent) hold on-demand-equivalent dollars, but an executor buys +// amount_usd_hr as commitment dollars, so firing one would over-buy by 1/rate. +// They are never claimed, even ahead of a due commitment row under a limit; +// they stay netted while within grace, and the overdue sweep cancels them +// after it, so the next run re-plans their gap in commitment dollars (#946). +func TestClaimDueLadderTranches_NeverClaimsOnDemandOrUnknownBasisRows(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + store := setupLadderStore(ctx, t) + cfgID := seedLadderConfigWithExtID(ctx, t, store, "688888888888") + asOf := time.Now().UTC().Truncate(time.Microsecond) + var calls atomic.Int64 + create := newExecCreator(ctx, t, store, &calls) + + unknown := lifecycleTranche(cfgID, 1, ladder.TrancheStatusScheduled, asOf.Add(-3*time.Hour)) + od := lifecycleTranche(cfgID, 2, ladder.TrancheStatusScheduled, asOf.Add(-2*time.Hour)) + odBasis := LadderAmountOnDemand + od.AmountBasis = &odBasis + native := claimableTranche(cfgID, 4, asOf.Add(-time.Hour)) + half := "1/2" + native.CommitmentRate = &half + require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{unknown, od, native})) + assert.Equal(t, 1.0+2+8, inFlight(ctx, t, store, cfgID, asOf), "4 commitment at rate 1/2 nets as 8 on-demand-equivalent") + + claimed, err := store.ClaimDueLadderTranches(ctx, cfgID, asOf, 1, create) + require.NoError(t, err) + require.Len(t, claimed, 1, "the older legacy rows must not use up the limit") + assert.Equal(t, native.ID, claimed[0].ID) + again, err := store.ClaimDueLadderTranches(ctx, cfgID, asOf, 10, create) + require.NoError(t, err) + assert.Empty(t, again, "legacy rows are never claimable") + assert.Equal(t, int64(1), calls.Load(), "no execution row for a legacy tranche") + assert.Equal(t, ladder.TrancheStatusScheduled, trancheStatus(ctx, t, store, unknown.ID)) + assert.Equal(t, ladder.TrancheStatusScheduled, trancheStatus(ctx, t, store, od.ID)) + assert.Equal(t, 1.0+2+8, inFlight(ctx, t, store, cfgID, asOf), "legacy rows stay netted within grace") + + later := asOf.Add(LadderFireGrace) + _, err = store.SaveLadderRunWithTranches(ctx, &LadderRunDB{ConfigID: &cfgID, StartedAt: later, Status: ladder.RunStatusPlanned}, nil) + require.NoError(t, err) + assert.Equal(t, ladder.TrancheStatusCancelled, trancheStatus(ctx, t, store, unknown.ID), "past grace the sweep cancels it") + assert.Equal(t, ladder.TrancheStatusCancelled, trancheStatus(ctx, t, store, od.ID), "past grace the sweep cancels it") +} + func TestClaimDueLadderTranches_CallbackFailureRollsBackClaimAndExecution(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() @@ -183,8 +237,8 @@ func TestClaimDueLadderTranches_CallbackFailureRollsBackClaimAndExecution(t *tes asOf := time.Now().UTC().Truncate(time.Microsecond) var calls atomic.Int64 create := newExecCreator(ctx, t, store, &calls) - first := lifecycleTranche(cfgID, 1, ladder.TrancheStatusScheduled, asOf.Add(-2*time.Hour)) - second := lifecycleTranche(cfgID, 2, ladder.TrancheStatusScheduled, asOf.Add(-time.Hour)) + first := claimableTranche(cfgID, 1, asOf.Add(-2*time.Hour)) + second := claimableTranche(cfgID, 2, asOf.Add(-time.Hour)) require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{first, second})) var execsBefore int require.NoError(t, store.db.QueryRow(ctx, `SELECT count(*) FROM purchase_executions`).Scan(&execsBefore)) @@ -220,10 +274,10 @@ func TestTransitionLadderTranche_EveryLegalEdgeAndCompareAndSet(t *testing.T) { create := newExecCreator(ctx, t, store, &calls) // scheduled -> canceled, once. - canceled := lifecycleTranche(cfgID, 1, ladder.TrancheStatusScheduled, asOf.Add(time.Hour)) + canceled := claimableTranche(cfgID, 1, asOf.Add(time.Hour)) // scheduled -> fired (claim) -> completed / failed. - toCompleted := lifecycleTranche(cfgID, 2, ladder.TrancheStatusScheduled, asOf.Add(-time.Hour)) - toFailed := lifecycleTranche(cfgID, 4, ladder.TrancheStatusScheduled, asOf.Add(-time.Hour)) + toCompleted := claimableTranche(cfgID, 2, asOf.Add(-time.Hour)) + toFailed := claimableTranche(cfgID, 4, asOf.Add(-time.Hour)) require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{canceled, toCompleted, toFailed})) require.Equal(t, 7.0, inFlight(ctx, t, store, cfgID, asOf)) @@ -273,7 +327,7 @@ func TestClaimDueLadderTranches_ConcurrentClaimersExactlyOneWinner(t *testing.T) asOf := time.Now().UTC().Truncate(time.Microsecond) var calls atomic.Int64 create := newExecCreator(ctx, t, store, &calls) - tr := lifecycleTranche(cfgID, 1, ladder.TrancheStatusScheduled, asOf.Add(-time.Hour)) + tr := claimableTranche(cfgID, 1, asOf.Add(-time.Hour)) require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{tr})) const n = 8 @@ -317,7 +371,7 @@ func TestClaimVsCancelAndOverdueSweep_ExactlyOneOutcome(t *testing.T) { create := newExecCreator(ctx, t, store, &calls) for i := 0; i < 25; i++ { - tr := lifecycleTranche(cfgID, 1, ladder.TrancheStatusScheduled, asOf.Add(-time.Hour)) + tr := claimableTranche(cfgID, 1, asOf.Add(-time.Hour)) require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{tr})) var claimWon, cancelWon bool var claimErr, cancelErr, sweepErr error diff --git a/internal/config/store_postgres_ladder_lock_integration_test.go b/internal/config/store_postgres_ladder_lock_integration_test.go index 92546b6b..36ef41cd 100644 --- a/internal/config/store_postgres_ladder_lock_integration_test.go +++ b/internal/config/store_postgres_ladder_lock_integration_test.go @@ -81,9 +81,7 @@ func TestLadderLock_ClaimWaitsForPlanner(t *testing.T) { defer cancel() store := setupLadderStore(ctx, t) cfgID := seedLadderConfigWithExtID(ctx, t, store, "711111111111") - tr := lifecycleTranche(cfgID, 1, ladder.TrancheStatusScheduled, time.Now().UTC().Add(-time.Hour)) - basis := LadderAmountOnDemand - tr.AmountBasis = &basis + tr := claimableTranche(cfgID, 1, time.Now().UTC().Add(-time.Hour)) require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{tr})) release := holdLadderLock(ctx, t, store, cfgID) @@ -226,7 +224,7 @@ func TestLadderLock_TimeoutBoundsOnlyTheWaitAndIsolationIsReadCommitted(t *testi pool.Reset() // new connections pick up the database default past := time.Now().UTC().Add(-time.Hour) - require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{lifecycleTranche(cfgID, 1, ladder.TrancheStatusScheduled, past)})) + require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{claimableTranche(cfgID, 1, past)})) inner := newExecCreator(ctx, t, store, new(atomic.Int64)) var lockTimeout, isolation string _, err = store.ClaimDueLadderTranches(ctx, cfgID, time.Now().UTC(), 1, func(c context.Context, tx pgx.Tx, tr LadderTrancheDB) (string, error) { diff --git a/internal/config/store_postgres_ladder_precision_integration_test.go b/internal/config/store_postgres_ladder_precision_integration_test.go index b9400ceb..0ea6061b 100644 --- a/internal/config/store_postgres_ladder_precision_integration_test.go +++ b/internal/config/store_postgres_ladder_precision_integration_test.go @@ -32,7 +32,8 @@ func TestLadderTrancheAmount_RoundTripsProviderPrecisionContract(t *testing.T) { planned := map[string]*big.Rat{} var rows []LadderTrancheDB add := func(amount float64, plan *big.Rat) { - tr := lifecycleTranche(cfgID, amount, ladder.TrancheStatusScheduled, due) + // Only commitment-basis rows are claimable (#946). + tr := claimableTranche(cfgID, amount, due) written[tr.ID], planned[tr.ID] = amount, plan rows = append(rows, tr) } diff --git a/internal/config/store_postgres_ladder_resolve_integration_test.go b/internal/config/store_postgres_ladder_resolve_integration_test.go index b16b9d96..c1cf301b 100644 --- a/internal/config/store_postgres_ladder_resolve_integration_test.go +++ b/internal/config/store_postgres_ladder_resolve_integration_test.go @@ -37,7 +37,7 @@ func newResolveRig(ctx context.Context, t *testing.T, store *PostgresStore, node cfgID := seedLadderConfigWithExtID(ctx, t, store, ext) r := &resolveRig{store: store, external: ext} require.NoError(t, store.db.QueryRow(ctx, `SELECT cloud_account_id::text FROM ladder_configs WHERE id = $1`, cfgID).Scan(&r.accountID)) - tr := lifecycleTranche(cfgID, 5, ladder.TrancheStatusScheduled, time.Now().Add(-time.Hour)) + tr := claimableTranche(cfgID, 5, time.Now().Add(-time.Hour)) require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{tr})) r.trancheID = tr.ID var ids []string diff --git a/internal/config/store_postgres_ladder_test.go b/internal/config/store_postgres_ladder_test.go index 814747eb..0fa027da 100644 --- a/internal/config/store_postgres_ladder_test.go +++ b/internal/config/store_postgres_ladder_test.go @@ -84,8 +84,21 @@ func TestLadderTrancheProvenance_InsertClaimAndReconcile(t *testing.T) { return create(ctx, tx, tr) }) require.NoError(t, err) - require.Len(t, claimed, len(rows)) - require.Equal(t, int64(3), calls.Load()) + // Only the commitment-dollar row is claimable (#946); fire the legacy NULL + // and on-demand rows directly so the fired-row reader still sees every basis. + require.Len(t, claimed, 1) + require.Equal(t, rows[2].ID, claimed[0].ID) + require.Equal(t, int64(1), calls.Load()) + for _, legacy := range rows[:2] { + require.NoError(t, store.WithTx(ctx, func(tx pgx.Tx) error { + execID, createErr := create(ctx, tx, legacy) + if createErr != nil { + return createErr + } + _, execErr := tx.Exec(ctx, `UPDATE ladder_tranches SET status = 'fired', execution_id = $2 WHERE id = $1`, legacy.ID, execID) + return execErr + })) + } fired, err := store.ListFiredLadderTranches(ctx, 10) require.NoError(t, err) require.Len(t, fired, len(rows)) diff --git a/internal/config/store_postgres_ladder_timeline.go b/internal/config/store_postgres_ladder_timeline.go index 792ba0b4..f560077b 100644 --- a/internal/config/store_postgres_ladder_timeline.go +++ b/internal/config/store_postgres_ladder_timeline.go @@ -40,6 +40,7 @@ WITH scoped AS ( r.baseline_usd_hr::text AS baseline_usd_hr, r.target_usd_hr::text AS target_usd_hr, r.existing_usd_hr::text AS existing_usd_hr, r.gap_usd_hr::text AS gap_usd_hr, r.total_hourly_commit::text AS total_hourly_commit, + r.plan->>'reason_code' AS reason_code, COALESCE((SELECT jsonb_agg(jsonb_build_object( 'action', action->'action', 'layer', action->'layer', 'rationale', action->'rationale', 'data_sources', action->'data_sources')) diff --git a/internal/config/store_postgres_ladder_timeline_integration_test.go b/internal/config/store_postgres_ladder_timeline_integration_test.go index 1302e5af..0a7a66b9 100644 --- a/internal/config/store_postgres_ladder_timeline_integration_test.go +++ b/internal/config/store_postgres_ladder_timeline_integration_test.go @@ -5,6 +5,7 @@ package config import ( "context" "encoding/json" + "strings" "testing" "time" @@ -140,3 +141,35 @@ func TestLadderTimelineMixedBasisOnDemand(t *testing.T) { _, err = store.ListLadderTimeline(ctx, accountID, "aws", nil) require.ErrorIs(t, err, ErrLadderTimelineRate) } + +// A run that could not size its gap carries the plan's reason_code to the runs +// API; an ordinary run (here a target-met Hold) has none, so the two Holds are +// told apart by code as well as by rationale. +func TestLadderTimelineRuns_ProjectsPlanReasonCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + store := setupLadderStore(ctx, t) + cfgID := seedLadderConfig(ctx, t, store) + var accountID string + require.NoError(t, store.db.QueryRow(ctx, "SELECT cloud_account_id FROM ladder_configs WHERE id=$1", cfgID).Scan(&accountID)) + start := time.Now().UTC() + for i, plan := range []string{ + `{"actions":[{"action":"hold","layer":"ec2-instance-sp","rationale":"target already met: gap=0"}]}`, + `{"reason_code":"missing_discount_rate","actions":[{"action":"hold","layer":"compute-sp","rationale":"Can't size this yet because layer compute-sp ..."}]}`, + } { + _, err := store.SaveLadderRun(ctx, &LadderRunDB{ConfigID: &cfgID, Status: ladder.RunStatusPlanned, + StartedAt: start.Add(time.Duration(i) * time.Second), Plan: []byte(plan)}) + require.NoError(t, err) + } + runs, err := store.ListLadderTimelineRuns(ctx, accountID, "aws", nil) + require.NoError(t, err) + require.Len(t, runs.Runs, 2) + codes := map[string]string{} + for _, run := range runs.Runs { + codes[run.Actions[0].Layer] = run.ReasonCode + } + require.Equal(t, map[string]string{"ec2-instance-sp": "", "compute-sp": "missing_discount_rate"}, codes) + raw, err := json.Marshal(runs) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(string(raw), `"reason_code"`), "an ordinary run omits reason_code") +} diff --git a/internal/purchase/ladder_reconcile_integration_test.go b/internal/purchase/ladder_reconcile_integration_test.go index 36cbe15a..595cd274 100644 --- a/internal/purchase/ladder_reconcile_integration_test.go +++ b/internal/purchase/ladder_reconcile_integration_test.go @@ -72,10 +72,10 @@ func (r *ladderRig) link(from, to string) { // firedTranche claims a real scheduled tranche with a real execution row. func (r *ladderRig) firedTranche(headStatus string, headProof bool, age time.Duration) (trancheID, headExec string) { - basis := config.LadderAmountOnDemand + basis, rate := config.LadderAmountCommitment, "1" // only commitment-dollar rows are claimable tr := config.LadderTrancheDB{ID: uuid.New().String(), ConfigID: &r.cfgID, LayerType: ladder.LayerComputeSP, Term: ladder.Term1Year, PaymentOption: ladder.PaymentNoUpfront, Status: ladder.TrancheStatusScheduled, - AmountUSDHr: 5, ScheduledDate: time.Now().Add(-time.Hour), AmountBasis: &basis} + AmountUSDHr: 5, ScheduledDate: time.Now().Add(-time.Hour), AmountBasis: &basis, CommitmentRate: &rate} require.NoError(r.t, r.store.SaveLadderTranches(r.ctx, []config.LadderTrancheDB{tr})) claimed, err := r.store.ClaimDueLadderTranches(r.ctx, r.cfgID, time.Now(), 10, func(ctx context.Context, tx pgx.Tx, _ config.LadderTrancheDB) (string, error) { diff --git a/internal/server/handler_ladder.go b/internal/server/handler_ladder.go index 25d34689..c2e17a53 100644 --- a/internal/server/handler_ladder.go +++ b/internal/server/handler_ladder.go @@ -15,6 +15,7 @@ import ( pkgcommon "github.com/LeanerCloud/cloud-commitments-go/pkg/common" pkgladder "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" + "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" "github.com/LeanerCloud/cloud-commitments-platform/internal/config" ) @@ -277,10 +278,16 @@ func (app *Application) processOneLadderConfig( // have not yet been bought. Tranches completed after `now` are counted too (see // below): a safe under-buy, and a deliberate deviation from go's InFlight doc. // -// Units: Allocate sizes the gap in on-demand-equivalent dollars and emits -// tranches in commitment dollars with the rate they were sized with. Rows are -// stored as basis=commitment plus that rate; the store nets each row back to -// on-demand-equivalent dollars by its own rate. +// Units: the provider's existing and expiring coverage arrive in commitment +// dollars and are first converted to on-demand-equivalent dollars with the +// same rates (ladderLayerStatesOnDemand). Coverage on a layer with no rate +// cannot be converted, so the gap cannot be sized: the run is persisted as a +// Hold-only run with reason_code missing_discount_rate that buys nothing +// (planMissingRateHold), not an Errored run. Allocate sizes the gap in +// on-demand-equivalent dollars and emits tranches in commitment dollars with +// the rate they were sized with. Rows are stored as basis=commitment plus that +// rate; the store nets each row back to on-demand-equivalent dollars by its +// own rate. // // Persistence is APPEND-ONLY (see persistLadderRun): every run inserts its new // scheduled tranches and NEVER supersedes existing ones. Netting already @@ -328,6 +335,15 @@ func (app *Application) executeLadderRun( return fmt.Errorf("GetCommitmentRates: %w", err) } + // Providers report existing coverage in commitment dollars; Allocate and + // the plan snapshot compare it with an on-demand-equivalent target (#946). + // Layers holding coverage with no rate come back in missing; the run is + // then a Hold that buys nothing and says why. + layerStates, missing, err := ladderLayerStatesOnDemand(layerStates, rates) + if err != nil { + return fmt.Errorf("existing coverage: %w", err) + } + // L5 netting runs inside the persist transaction (PlanAndSaveLadderRun): // the store reads the config's in-flight commitment (scheduled tranches // still within config.LadderFireGrace plus fired, unresolved ones) under @@ -339,14 +355,13 @@ func (app *Application) executeLadderRun( // network I/O. in := &ladderRunPlanner{ dbCfg: dbCfg, engineCfg: engineCfg, baseline: baseline, layerStates: layerStates, rates: rates, - layers: capability.SupportedLayers(), term: term, paymentOpt: paymentOpt, now: now, + layers: capability.SupportedLayers(), term: term, paymentOpt: paymentOpt, now: now, missing: missing, } - savedRun, err := app.Config.PlanAndSaveLadderRun(ctx, dbCfg.ID, now, in.plan) + savedRun, err := app.Config.PlanAndSaveLadderRun(ctx, dbCfg.ID, now, in.build) if err != nil { return fmt.Errorf("persist ladder run: %w", err) } - log.Printf("ladder_run: config %s: persisted run %s (status=%s, allocations=%d, tranches=%d, holds=%d)", - dbCfg.ID, savedRun.ID, savedRun.Status, in.allocations, in.tranches, in.holds) + in.logPersisted(savedRun) return nil } @@ -363,10 +378,44 @@ type ladderRunPlanner struct { term pkgladder.Term paymentOpt pkgladder.PaymentOption now time.Time + // missing lists the layers whose existing coverage has no rate. When set, + // the run is a Hold-only run (planMissingRateHold) and layerStates is nil. + missing []ladderMissingRate allocations, tranches, holds int // set by plan, for the log line } +// build is the LadderPlanBuilder handed to PlanAndSaveLadderRun. +func (p *ladderRunPlanner) build(rows []config.LadderInFlightRow) (*config.LadderRunDB, []config.LadderTrancheDB, error) { + if len(p.missing) > 0 { + return p.planMissingRateHold() + } + return p.plan(rows) +} + +// reasonCode is the plan's machine-readable reason_code: set only for a run +// whose gap could not be sized. An ordinary run, including a target-met Hold, +// has none and explains itself in its actions' rationale. +func (p *ladderRunPlanner) reasonCode() string { + if len(p.missing) > 0 { + return ladderHoldMissingDiscountRate + } + return "" +} + +// logPersisted logs the saved run. A missing-rate Hold is logged at WARN, +// once per run: it is an expected state until the provider returns a rate, +// not a failure, and the run counts as planned. +func (p *ladderRunPlanner) logPersisted(run *config.LadderRunDB) { + if len(p.missing) > 0 { + logging.Warnf("ladder_run: config %s: persisted Hold-only run %s (reason_code=%s): %d layer(s) hold existing coverage with no discount rate; nothing bought", + p.dbCfg.ID, run.ID, ladderHoldMissingDiscountRate, len(p.missing)) + return + } + log.Printf("ladder_run: config %s: persisted run %s (status=%s, allocations=%d, tranches=%d, holds=%d)", + p.dbCfg.ID, run.ID, run.Status, p.allocations, p.tranches, p.holds) +} + func (p *ladderRunPlanner) plan(rows []config.LadderInFlightRow) (*config.LadderRunDB, []config.LadderTrancheDB, error) { // Allocate rejects a row on a layer outside Layers, so it cannot be silently un-netted. inFlight := make([]pkgladder.InFlightTranche, len(rows)) @@ -433,7 +482,7 @@ func (p *ladderRunPlanner) buildRows(runID string, plan *pkgladder.LadderPlan, t if err := plan.Validate(); err != nil { return nil, nil, fmt.Errorf("LadderPlan.Validate: %w", err) } - planJSON, err := marshalLadderPlan(plan) + planJSON, err := marshalLadderPlan(plan, p.reasonCode()) if err != nil { return nil, nil, fmt.Errorf("marshal plan: %w", err) } @@ -552,7 +601,8 @@ func ladderConfigToEngine(dbCfg *config.LadderConfigDB, accountID string) (pkgla // The monetary snapshot is derived to stay consistent with the authoritative // allocation rather than recomputed independently: // - target = baseline low-water * targetCoveragePct/100 (the coverage goal), -// - existing = sum of per-layer ExistingUSDPerHour, +// - existing = sum of per-layer ExistingUSDPerHour, already converted to +// on-demand-equivalent dollars by ladderLayerStatesOnDemand, // - gap = sum of the planned allocations' on-demand-equivalent gaps (the unit // of target and existing), NOT target - existing. // @@ -596,7 +646,8 @@ func assembleLadderPlan( // Holds are informational; append last. actions = append(actions, allocResult.Holds...) - // existing = sum of per-layer existing commitment (always reportable). + // existing = sum of per-layer on-demand-equivalent existing coverage + // (always reportable). var existingTotal float64 for _, ls := range layerStates { if ls.ExistingUSDPerHour != nil { @@ -607,13 +658,9 @@ func assembleLadderPlan( // target and gap are only meaningful when the baseline is available; nil // stays nil (a Hold-only no-op has no purchase target/gap to report). - var target, gap *big.Rat + target := ladderPlanTarget(baseline, targetCoveragePct) + var gap *big.Rat if baseline.LowWaterUSDPerHour != nil { - if low := ratFromFloat(*baseline.LowWaterUSDPerHour); low != nil { - if pctFrac := ratFromFloat(targetCoveragePct / 100.0); pctFrac != nil { - target = new(big.Rat).Mul(low, pctFrac) - } - } // gap is what is being placed this run in on-demand-equivalent dollars, // so it compares with target and existing; total_hourly_commit is the // same placement in commitment dollars. @@ -631,6 +678,20 @@ func assembleLadderPlan( } } +// ladderPlanTarget is the plan's on-demand-equivalent target, baseline +// low-water * targetCoveragePct/100. nil when the baseline is unavailable. +func ladderPlanTarget(baseline pkgladder.UsageBaseline, targetCoveragePct float64) *big.Rat { + if baseline.LowWaterUSDPerHour == nil { + return nil + } + low := ratFromFloat(*baseline.LowWaterUSDPerHour) + pctFrac := ratFromFloat(targetCoveragePct / 100.0) + if low == nil || pctFrac == nil { + return nil + } + return new(big.Rat).Mul(low, pctFrac) +} + // ratFromFloat converts a float64 to *big.Rat. It returns nil when // big.Rat.SetFloat64 returns nil (non-finite inputs like NaN or Inf). // This mirrors the project-wide convention for converting float64 to exact @@ -791,6 +852,9 @@ type ladderPlanJSONAction struct { // The *big.Rat monetary fields are converted to *float64 so they round-trip // cleanly through JSONB without binary encoding. type ladderPlanJSONDTO struct { + // ReasonCode is set only when the run could not size its gap (see + // ladderRunPlanner.reasonCode); ordinary runs omit it. + ReasonCode string `json:"reason_code,omitempty"` Scope pkgladder.Scope `json:"scope"` GeneratedAt time.Time `json:"generated_at"` TargetUSDHr *float64 `json:"target_usd_hr,omitempty"` @@ -802,8 +866,8 @@ type ladderPlanJSONDTO struct { // marshalLadderPlan serializes a LadderPlan to JSON for storage in the // plan JSONB column. *big.Rat fields are converted to *float64 at this -// boundary; nil stays nil. -func marshalLadderPlan(plan *pkgladder.LadderPlan) (json.RawMessage, error) { +// boundary; nil stays nil. reasonCode is stored as reason_code when non-empty. +func marshalLadderPlan(plan *pkgladder.LadderPlan, reasonCode string) (json.RawMessage, error) { actions := make([]ladderPlanJSONAction, 0, len(plan.Actions)) for _, a := range plan.Actions { action := ladderPlanJSONAction{ @@ -818,6 +882,7 @@ func marshalLadderPlan(plan *pkgladder.LadderPlan) (json.RawMessage, error) { actions = append(actions, action) } dto := ladderPlanJSONDTO{ + ReasonCode: reasonCode, Scope: plan.Scope, GeneratedAt: plan.GeneratedAt, TargetUSDHr: ratToFloat64Ptr(plan.TargetUSDPerHour), diff --git a/internal/server/ladder_existing_units.go b/internal/server/ladder_existing_units.go new file mode 100644 index 00000000..3493d1c6 --- /dev/null +++ b/internal/server/ladder_existing_units.go @@ -0,0 +1,211 @@ +package server + +import ( + "fmt" + "maps" + "math" + "math/big" + "slices" + + "github.com/google/uuid" + + pkgladder "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" +) + +// Existing coverage units (#946). +// +// Providers report LayerState.ExistingUSDPerHour and ExpiringUSDPerHour in +// native commitment dollars per hour: the AWS provider sums +// SavingsPlan.Commitment for the Savings Plan layers and the amortized RI cost +// for the RI layer. Allocate compares them with an on-demand-equivalent (OD) +// target, and the pkg/ladder LayerState contract says callers must convert +// them first. With a commitment rate r in (0, 1], the commitment dollars paid +// per OD dollar covered: +// +// od = commitment / r +// +// so 5 commitment $/hr at r = 0.5 covers 10 OD $/hr. The rate is today's +// new-purchase rate for the config's term and payment option, from +// GetCommitmentRates (option 2 on #946): DescribeSavingsPlans does not expose +// each plan's own rate, so plans bought at a different discount are +// approximated. +// +// Rounding favors under-buying. When the exact quotient is not a float64, +// existing is rounded up and expiring down, so the net existing coverage +// (existing - expiring) Allocate subtracts from the target is never +// understated. +// +// A layer with non-zero existing or expiring commitment and no rate cannot be +// converted. 1:1 would understate its coverage and over-buy, and no value is a +// safe upper bound, so the gap cannot be sized. The run is persisted as a +// Hold that buys nothing and says why (planMissingRateHold), rather than +// guessing a rate or failing on every tick (owner decision D3 on #1238). + +// ladderMissingRate is one layer whose existing or expiring commitment needs a +// rate the provider did not return. Amounts are commitment USD/hr. +type ladderMissingRate struct { + layer pkgladder.LayerType + existing, expiring *float64 +} + +// ladderLayerStatesOnDemand returns a copy of states with ExistingUSDPerHour +// and ExpiringUSDPerHour converted from commitment dollars to +// on-demand-equivalent dollars by each layer's rate. The input map and its +// values are not modified. Nil amounts stay nil for Allocate to reject. +// +// When any layer holds commitment but has no rate, it returns a nil map and +// every such layer (sorted by layer) instead: the gap cannot be sized, and the +// caller must not hand a partial conversion to Allocate. Invalid provider data +// (a rate outside (0, 1], a negative or non-finite amount) is an error. +func ladderLayerStatesOnDemand( + states map[pkgladder.LayerType]pkgladder.LayerState, + rates map[pkgladder.LayerType]float64, +) (map[pkgladder.LayerType]pkgladder.LayerState, []ladderMissingRate, error) { + out := make(map[pkgladder.LayerType]pkgladder.LayerState, len(states)) + var missing []ladderMissingRate + for _, layer := range slices.Sorted(maps.Keys(states)) { + st := states[layer] + rate, hasRate := rates[layer] + if !hasRate && holdsCommitment(st) { + if err := validateCommitments(st); err != nil { + return nil, nil, fmt.Errorf("layer %s: %w", layer, err) + } + missing = append(missing, ladderMissingRate{layer: layer, existing: st.ExistingUSDPerHour, expiring: st.ExpiringUSDPerHour}) + continue + } + od, err := layerStateOnDemand(st, rate) + if err != nil { + return nil, nil, fmt.Errorf("layer %s: %w", layer, err) + } + out[layer] = od + } + if len(missing) > 0 { + return nil, missing, nil + } + return out, nil, nil +} + +// validateCommitments checks the amounts of a layer that cannot be converted, +// so bad provider data still fails the run instead of reaching a Hold message. +func validateCommitments(st pkgladder.LayerState) error { + if _, err := commitmentToOnDemand("existing", st.ExistingUSDPerHour, big.NewRat(1, 1), true); err != nil { + return err + } + _, err := commitmentToOnDemand("expiring", st.ExpiringUSDPerHour, big.NewRat(1, 1), false) + return err +} + +// layerStateOnDemand converts one layer's existing and expiring commitment. +// A layer with no commitment (both amounts nil or zero) needs no rate. +func layerStateOnDemand(st pkgladder.LayerState, rate float64) (pkgladder.LayerState, error) { + if !holdsCommitment(st) { + return st, nil + } + r, err := commitmentRateRat(rate) + if err != nil { + return st, err + } + existing, err := commitmentToOnDemand("existing", st.ExistingUSDPerHour, r, true) + if err != nil { + return st, err + } + expiring, err := commitmentToOnDemand("expiring", st.ExpiringUSDPerHour, r, false) + if err != nil { + return st, err + } + st.ExistingUSDPerHour, st.ExpiringUSDPerHour = existing, expiring + return st, nil +} + +func isZeroOrNil(v *float64) bool { return v == nil || *v == 0 } + +// holdsCommitment reports whether a layer has existing or expiring commitment +// that must be converted (and so needs a rate). +func holdsCommitment(st pkgladder.LayerState) bool { + return !isZeroOrNil(st.ExistingUSDPerHour) || !isZeroOrNil(st.ExpiringUSDPerHour) +} + +func fmtFloatPtr(v *float64) string { + if v == nil { + return "nil" + } + return fmt.Sprintf("%g", *v) +} + +// commitmentRateRat validates a provider rate the same way Allocate does: +// finite and in (0, 1]. +func commitmentRateRat(rate float64) (*big.Rat, error) { + if math.IsNaN(rate) || math.IsInf(rate, 0) || rate <= 0 || rate > 1 { + return nil, fmt.Errorf("commitment rate %g must be in (0, 1]", rate) + } + return new(big.Rat).SetFloat64(rate), nil +} + +// commitmentToOnDemand returns commitment/rate as a float64, rounded up when +// roundUp is set and down otherwise. Nil stays nil. +func commitmentToOnDemand(name string, commitment *float64, rate *big.Rat, roundUp bool) (*float64, error) { + if commitment == nil { + return nil, nil + } + c := ratFromFloat(*commitment) + if c == nil || c.Sign() < 0 { + return nil, fmt.Errorf("%s commitment %g USD/hr must be finite and non-negative", name, *commitment) + } + exact := c.Quo(c, rate) + od, _ := exact.Float64() + if math.IsInf(od, 0) { + return nil, fmt.Errorf("%s on-demand-equivalent amount exceeds float64", name) + } + if cmp := new(big.Rat).SetFloat64(od).Cmp(exact); roundUp && cmp < 0 { + od = math.Nextafter(od, math.Inf(1)) + } else if !roundUp && cmp > 0 { + od = math.Nextafter(od, 0) + } + if math.IsInf(od, 0) { + return nil, fmt.Errorf("%s on-demand-equivalent amount exceeds float64", name) + } + return &od, nil +} + +// ladderHoldMissingDiscountRate is the plan reason_code of a Hold-only run +// whose gap could not be sized because existing coverage has no discount rate. +// A Hold from Allocate (target met, baseline unavailable, ...) has no +// reason_code. +const ladderHoldMissingDiscountRate = "missing_discount_rate" + +// planMissingRateHold builds the run persisted when existing coverage on one +// or more layers has no rate (see ladderLayerStatesOnDemand). Allocate never +// runs, so no rate is guessed and nothing is bought: one Hold per layer says +// which layer could not be sized and why. Existing and gap stay nil +// (unmeasured, never 0), so the amend ceiling refuses any raise against this +// run. The in-flight rows are not needed. +func (p *ladderRunPlanner) planMissingRateHold() (*config.LadderRunDB, []config.LadderTrancheDB, error) { + actions := make([]pkgladder.PlannedAction, 0, len(p.missing)) + for _, m := range p.missing { + actions = append(actions, pkgladder.PlannedAction{ + Action: pkgladder.ActionHold, + Layer: m.layer, + Rationale: m.rationale(p.term, p.paymentOpt), + DataSources: []string{dataSourceAWSCostExplorer}, + }) + } + plan := &pkgladder.LadderPlan{ + Scope: p.engineCfg.Scope, + GeneratedAt: p.now, + TargetUSDPerHour: ladderPlanTarget(p.baseline, p.engineCfg.TargetCoveragePct), + Actions: actions, + Baseline: p.baseline, + } + p.holds = len(actions) + return p.buildRows(uuid.New().String(), plan, &pkgladder.TrancheResult{}) +} + +// rationale is the Hold's human message. It must read as "not sized yet", never +// as "covered", so it is not mistaken for a target-met Hold. +func (m ladderMissingRate) rationale(term pkgladder.Term, payment pkgladder.PaymentOption) string { + return fmt.Sprintf("Can't size this yet because layer %s holds existing coverage (%s commitment USD/hr, %s expiring) "+ + "and the provider returned no discount rate for it at %s %s, so that coverage cannot be converted to on-demand-equivalent dollars. "+ + "Nothing is bought this run: the rate is never estimated or assumed 1:1. The ladder sizes the gap once a rate is available.", + m.layer, fmtFloatPtr(m.existing), fmtFloatPtr(m.expiring), term, payment) +} diff --git a/internal/server/ladder_existing_units_test.go b/internal/server/ladder_existing_units_test.go new file mode 100644 index 00000000..c3886439 --- /dev/null +++ b/internal/server/ladder_existing_units_test.go @@ -0,0 +1,184 @@ +package server + +import ( + "encoding/json" + "math" + "math/big" + "testing" + + pkgladder "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Units in these tests: "commitment" is commitment USD/hr as the provider +// reports it (SavingsPlan.Commitment); "OD" is on-demand-equivalent USD/hr. +// od = commitment / rate. + +func f64(v float64) *float64 { return &v } + +func spState(existing, expiring *float64) pkgladder.LayerState { + return pkgladder.LayerState{Layer: pkgladder.LayerEC2InstanceSP, ExistingUSDPerHour: existing, ExpiringUSDPerHour: expiring} +} + +func TestLadderLayerStatesOnDemand_PinsCommitmentToOnDemandConversion(t *testing.T) { + cases := []struct { + name string + existing, expiring float64 + rate float64 + wantExisting, wantExpg float64 + }{ + {"half rate doubles", 5, 2, 0.5, 10, 4}, + {"quarter rate quadruples", 3, 1, 0.25, 12, 4}, + {"rate one is identity", 7, 3, 1, 7, 3}, + {"zero expiring stays zero", 6, 0, 0.75, 8, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + states := map[pkgladder.LayerType]pkgladder.LayerState{ + pkgladder.LayerEC2InstanceSP: spState(f64(tc.existing), f64(tc.expiring)), + } + out, missing, err := ladderLayerStatesOnDemand(states, map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: tc.rate}) + require.NoError(t, err) + require.Empty(t, missing) + got := out[pkgladder.LayerEC2InstanceSP] + assert.Equal(t, tc.wantExisting, *got.ExistingUSDPerHour, "existing OD") + assert.Equal(t, tc.wantExpg, *got.ExpiringUSDPerHour, "expiring OD") + assert.Equal(t, pkgladder.LayerEC2InstanceSP, got.Layer) + }) + } +} + +// 1/0.3 is not a float64. Existing must round up and expiring down, so the +// net existing coverage (existing - expiring) is never understated, which +// would over-buy. +func TestLadderLayerStatesOnDemand_RoundsTowardUnderBuying(t *testing.T) { + const rate = 0.3 + states := map[pkgladder.LayerType]pkgladder.LayerState{ + pkgladder.LayerEC2InstanceSP: spState(f64(1), f64(1)), + } + out, _, err := ladderLayerStatesOnDemand(states, map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: rate}) + require.NoError(t, err) + exact := new(big.Rat).Quo(big.NewRat(1, 1), new(big.Rat).SetFloat64(rate)) + + existing := *out[pkgladder.LayerEC2InstanceSP].ExistingUSDPerHour + require.GreaterOrEqual(t, new(big.Rat).SetFloat64(existing).Cmp(exact), 0, "existing must not be below the exact OD") + require.Less(t, new(big.Rat).SetFloat64(math.Nextafter(existing, 0)).Cmp(exact), 0, "existing is the nearest float at or above") + + expiring := *out[pkgladder.LayerEC2InstanceSP].ExpiringUSDPerHour + require.LessOrEqual(t, new(big.Rat).SetFloat64(expiring).Cmp(exact), 0, "expiring must not be above the exact OD") + require.Greater(t, new(big.Rat).SetFloat64(math.Nextafter(expiring, math.Inf(1))).Cmp(exact), 0, "expiring is the nearest float at or below") +} + +func TestLadderLayerStatesOnDemand_DoesNotMutateInput(t *testing.T) { + existing, expiring := f64(5), f64(2) + states := map[pkgladder.LayerType]pkgladder.LayerState{pkgladder.LayerEC2InstanceSP: spState(existing, expiring)} + _, _, err := ladderLayerStatesOnDemand(states, map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: 0.5}) + require.NoError(t, err) + assert.Equal(t, 5.0, *existing) + assert.Equal(t, 2.0, *expiring) + assert.Same(t, existing, states[pkgladder.LayerEC2InstanceSP].ExistingUSDPerHour) +} + +// A layer with no commitment needs no rate (the AWS provider has none for +// Compute SP or Convertible RI today); nil amounts stay nil for Allocate. +func TestLadderLayerStatesOnDemand_EmptyLayersNeedNoRate(t *testing.T) { + states := map[pkgladder.LayerType]pkgladder.LayerState{ + pkgladder.LayerComputeSP: {Layer: pkgladder.LayerComputeSP, ExistingUSDPerHour: f64(0), ExpiringUSDPerHour: f64(0)}, + pkgladder.LayerConvertibleRI: {Layer: pkgladder.LayerConvertibleRI}, + } + out, missing, err := ladderLayerStatesOnDemand(states, map[pkgladder.LayerType]float64{}) + require.NoError(t, err) + require.Empty(t, missing) + assert.Equal(t, 0.0, *out[pkgladder.LayerComputeSP].ExistingUSDPerHour) + assert.Nil(t, out[pkgladder.LayerConvertibleRI].ExistingUSDPerHour) + assert.Nil(t, out[pkgladder.LayerConvertibleRI].ExpiringUSDPerHour) +} + +func TestLadderLayerStatesOnDemand_RejectsUnconvertibleCoverage(t *testing.T) { + cases := []struct { + name string + state pkgladder.LayerState + rates map[pkgladder.LayerType]float64 + wantErr string + }{ + {"negative existing without a rate", spState(f64(-5), f64(0)), map[pkgladder.LayerType]float64{pkgladder.LayerComputeSP: 0.5}, "non-negative"}, + {"NaN expiring without a rate", spState(f64(0), f64(math.NaN())), nil, "non-negative"}, + {"zero rate", spState(f64(5), f64(0)), map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: 0}, "must be in (0, 1]"}, + {"rate above one", spState(f64(5), f64(0)), map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: 1.5}, "must be in (0, 1]"}, + {"NaN rate", spState(f64(5), f64(0)), map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: math.NaN()}, "must be in (0, 1]"}, + {"negative existing", spState(f64(-1), f64(0)), map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: 0.5}, "non-negative"}, + {"NaN expiring", spState(f64(1), f64(math.NaN())), map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: 0.5}, "non-negative"}, + {"overflow", spState(f64(math.MaxFloat64), f64(0)), map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: 0.5}, "exceeds float64"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := ladderLayerStatesOnDemand(map[pkgladder.LayerType]pkgladder.LayerState{pkgladder.LayerEC2InstanceSP: tc.state}, tc.rates) + require.ErrorContains(t, err, tc.wantErr) + assert.ErrorContains(t, err, string(pkgladder.LayerEC2InstanceSP)) + }) + } +} + +// End to end through executeLadderRun: low-water 12.5 at 80% coverage is a +// target of 10 OD. An existing EC2 Instance SP of 2.5 commitment at rate 0.5 +// covers 5 OD, so the run places 5 OD = 2.5 commitment. Treating the 2.5 as +// OD (the bug) placed 7.5 OD = 3.75 commitment. +func TestExecuteLadderRun_ExistingSavingsPlanCoverageIsOnDemandEquivalent(t *testing.T) { + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5), + rates: map[pkgladder.LayerType]float64{pkgladder.LayerConvertibleRI: 0.5, pkgladder.LayerEC2InstanceSP: 0.5}, + layerStates: map[pkgladder.LayerType]pkgladder.LayerState{ + pkgladder.LayerConvertibleRI: {Layer: pkgladder.LayerConvertibleRI, ExistingUSDPerHour: f64(0), ExpiringUSDPerHour: f64(0)}, + pkgladder.LayerEC2InstanceSP: spState(f64(2.5), f64(0)), + }} + require.NoError(t, runWithRates(t, store, cap)) + + run := store.savedRun + require.NotNil(t, run) + require.NotNil(t, run.ExistingUSDHr) + assert.InDelta(t, 5.0, *run.ExistingUSDHr, 1e-9, "existing_usd_hr is OD, comparable with target") + require.NotNil(t, run.GapUSDHr) + assert.InDelta(t, 5.0, *run.GapUSDHr, 1e-9, "gap OD = target 10 - existing 5") + assert.InDelta(t, 2.5, run.TotalHourlyCommit, 1e-9, "5 OD at rate 1/2 is 2.5 commitment") + + var dto ladderPlanJSONDTO + require.NoError(t, json.Unmarshal(run.Plan, &dto)) + require.NotNil(t, dto.ExistingUSDHr) + assert.InDelta(t, 5.0, *dto.ExistingUSDHr, 1e-9, "plan JSON existing is OD") +} + +// Expiring coverage converts by the same rate: existing 4 and expiring 2 +// commitment at rate 0.5 leave a net 4 OD, so the gap is 10 - 4 = 6 OD. +func TestExecuteLadderRun_ExpiringCoverageIsOnDemandEquivalent(t *testing.T) { + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5), + rates: map[pkgladder.LayerType]float64{pkgladder.LayerConvertibleRI: 0.5, pkgladder.LayerEC2InstanceSP: 0.5}, + layerStates: map[pkgladder.LayerType]pkgladder.LayerState{ + pkgladder.LayerConvertibleRI: {Layer: pkgladder.LayerConvertibleRI, ExistingUSDPerHour: f64(0), ExpiringUSDPerHour: f64(0)}, + pkgladder.LayerEC2InstanceSP: spState(f64(4), f64(2)), + }} + require.NoError(t, runWithRates(t, store, cap)) + require.NotNil(t, store.savedRun.GapUSDHr) + assert.InDelta(t, 6.0, *store.savedRun.GapUSDHr, 1e-9) + assert.InDelta(t, 3.0, store.savedRun.TotalHourlyCommit, 1e-9) +} + +// Existing or expiring commitment on a layer with no rate cannot be +// converted. Every such layer is reported, in layer order, with no partial +// conversion for Allocate; layers that hold nothing still need no rate. +func TestLadderLayerStatesOnDemand_ReportsEveryLayerWithoutARate(t *testing.T) { + states := map[pkgladder.LayerType]pkgladder.LayerState{ + pkgladder.LayerEC2InstanceSP: spState(f64(4), f64(0)), + pkgladder.LayerConvertibleRI: {Layer: pkgladder.LayerConvertibleRI, ExistingUSDPerHour: f64(3), ExpiringUSDPerHour: f64(0)}, + pkgladder.LayerComputeSP: {Layer: pkgladder.LayerComputeSP, ExistingUSDPerHour: f64(0), ExpiringUSDPerHour: f64(1)}, + pkgladder.LayerAzureSavingsPlan: {Layer: pkgladder.LayerAzureSavingsPlan}, + } + out, missing, err := ladderLayerStatesOnDemand(states, map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: 0.5}) + require.NoError(t, err) + assert.Nil(t, out, "no partial conversion reaches Allocate") + require.Equal(t, []ladderMissingRate{ + {layer: pkgladder.LayerComputeSP, existing: f64(0), expiring: f64(1)}, + {layer: pkgladder.LayerConvertibleRI, existing: f64(3), expiring: f64(0)}, + }, missing) +} diff --git a/internal/server/ladder_missing_rate_hold_test.go b/internal/server/ladder_missing_rate_hold_test.go new file mode 100644 index 00000000..004c4411 --- /dev/null +++ b/internal/server/ladder_missing_rate_hold_test.go @@ -0,0 +1,157 @@ +package server + +import ( + "encoding/json" + "testing" + + pkgladder "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Owner decision D3 on #1238: existing coverage with no discount rate makes the +// run a persisted Hold that buys nothing and says why, instead of an Errored +// run on every tick. Low-water 12.5 at 80% coverage is a target of 10 OD. + +// awsLadderLayers mirrors the AWS provider's three layers so every layer kind +// can hold coverage. +var awsLadderLayers = []pkgladder.LayerSpec{ + {Type: pkgladder.LayerConvertibleRI, Roles: []pkgladder.LayerRole{pkgladder.RoleBase}}, + {Type: pkgladder.LayerComputeSP, Roles: []pkgladder.LayerRole{pkgladder.RoleFlex}}, + {Type: pkgladder.LayerEC2InstanceSP, Roles: []pkgladder.LayerRole{pkgladder.RoleBase}}, +} + +// ladderPlanJSON decodes the stored plan generically, so the assertions do not +// depend on the DTO type. +type ladderPlanJSON struct { + ReasonCode string `json:"reason_code"` + Actions []struct { + Action string `json:"action"` + Layer string `json:"layer"` + Rationale string `json:"rationale"` + } `json:"actions"` +} + +func decodeLadderPlan(t *testing.T, raw []byte) ladderPlanJSON { + t.Helper() + var plan ladderPlanJSON + require.NoError(t, json.Unmarshal(raw, &plan)) + return plan +} + +func emptyLayerState(layer pkgladder.LayerType) pkgladder.LayerState { + return pkgladder.LayerState{Layer: layer, ExistingUSDPerHour: f64(0), ExpiringUSDPerHour: f64(0)} +} + +// Each AWS layer kind with existing coverage and no rate: Compute SP and +// Convertible RI (the provider has no rate for them today) and EC2 Instance SP +// (Cost Explorer returned none). The other layers have rates, so only the +// missing one stops sizing. +func TestExecuteLadderRun_MissingDiscountRatePersistsHoldWithReason(t *testing.T) { + allRates := map[pkgladder.LayerType]float64{ + pkgladder.LayerConvertibleRI: 0.5, pkgladder.LayerComputeSP: 0.5, pkgladder.LayerEC2InstanceSP: 0.5, + } + for _, missing := range []pkgladder.LayerType{pkgladder.LayerComputeSP, pkgladder.LayerConvertibleRI, pkgladder.LayerEC2InstanceSP} { + t.Run(string(missing), func(t *testing.T) { + rates := map[pkgladder.LayerType]float64{} + states := map[pkgladder.LayerType]pkgladder.LayerState{} + for _, spec := range awsLadderLayers { + states[spec.Type] = emptyLayerState(spec.Type) + if spec.Type != missing { + rates[spec.Type] = allRates[spec.Type] + } + } + states[missing] = pkgladder.LayerState{Layer: missing, ExistingUSDPerHour: f64(2.5), ExpiringUSDPerHour: f64(0.5)} + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5), rates: rates, + layerStates: states, supportedLayers: awsLadderLayers} + + require.NoError(t, runWithRates(t, store, cap), "a missing rate is a Hold, not an Errored run") + + run := store.savedRun + require.NotNil(t, run, "the Hold run is persisted") + assert.Equal(t, pkgladder.RunStatusPlanned, run.Status) + assert.Empty(t, store.savedTranches, "nothing is bought") + assert.Zero(t, run.TotalHourlyCommit) + assert.Nil(t, run.ExistingUSDHr, "existing coverage is unmeasured, never netted 1:1") + assert.Nil(t, run.GapUSDHr, "the gap is not sized") + require.NotNil(t, run.TargetUSDHr) + assert.InDelta(t, 10.0, *run.TargetUSDHr, 1e-9) + + plan := decodeLadderPlan(t, run.Plan) + assert.Equal(t, "missing_discount_rate", plan.ReasonCode) + require.Len(t, plan.Actions, 1) + hold := plan.Actions[0] + assert.Equal(t, string(pkgladder.ActionHold), hold.Action) + assert.Equal(t, string(missing), hold.Layer) + assert.Regexp(t, `^Can't size this yet because layer `+string(missing)+` `, hold.Rationale) + assert.Contains(t, hold.Rationale, "2.5 commitment USD/hr, 0.5 expiring") + assert.Contains(t, hold.Rationale, "no discount rate") + assert.Contains(t, hold.Rationale, "1yr no-upfront") + assert.NotContains(t, hold.Rationale, "already met") + }) + } +} + +// Every layer without a rate gets its own Hold, in layer order. +func TestExecuteLadderRun_MissingDiscountRateNamesEveryLayer(t *testing.T) { + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5), supportedLayers: awsLadderLayers, + rates: map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: 0.5}, + layerStates: map[pkgladder.LayerType]pkgladder.LayerState{ + pkgladder.LayerConvertibleRI: {Layer: pkgladder.LayerConvertibleRI, ExistingUSDPerHour: f64(3), ExpiringUSDPerHour: f64(0)}, + pkgladder.LayerComputeSP: {Layer: pkgladder.LayerComputeSP, ExistingUSDPerHour: f64(0), ExpiringUSDPerHour: f64(1)}, + pkgladder.LayerEC2InstanceSP: spState(f64(4), f64(0)), + }} + require.NoError(t, runWithRates(t, store, cap)) + plan := decodeLadderPlan(t, store.savedRun.Plan) + assert.Equal(t, "missing_discount_rate", plan.ReasonCode) + require.Len(t, plan.Actions, 2) + assert.Equal(t, string(pkgladder.LayerComputeSP), plan.Actions[0].Layer) + assert.Equal(t, string(pkgladder.LayerConvertibleRI), plan.Actions[1].Layer) + assert.Empty(t, store.savedTranches) +} + +// A genuinely met target is still the ordinary Hold: no reason_code and +// Allocate's "target already met" rationale. 20 commitment at rate 0.5 covers +// 20 OD, above the target of 10. +func TestExecuteLadderRun_TargetMetHoldKeepsOrdinaryReason(t *testing.T) { + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5), + rates: map[pkgladder.LayerType]float64{pkgladder.LayerConvertibleRI: 0.5, pkgladder.LayerEC2InstanceSP: 0.5}, + layerStates: map[pkgladder.LayerType]pkgladder.LayerState{ + pkgladder.LayerConvertibleRI: emptyLayerState(pkgladder.LayerConvertibleRI), + pkgladder.LayerEC2InstanceSP: spState(f64(10), f64(0)), + }} + require.NoError(t, runWithRates(t, store, cap)) + + run := store.savedRun + require.NotNil(t, run) + assert.Empty(t, store.savedTranches) + require.NotNil(t, run.ExistingUSDHr) + assert.InDelta(t, 20.0, *run.ExistingUSDHr, 1e-9) + plan := decodeLadderPlan(t, run.Plan) + assert.Empty(t, plan.ReasonCode, "a target-met Hold has no reason_code") + require.NotEmpty(t, plan.Actions) + for _, a := range plan.Actions { + assert.Equal(t, string(pkgladder.ActionHold), a.Action) + assert.NotContains(t, a.Rationale, "Can't size this yet") + } + assert.Regexp(t, `^target already met: `, plan.Actions[0].Rationale) +} + +// Bad provider data on a layer without a rate still fails the run: the Hold is +// only for a rate that is missing, not for amounts that cannot be trusted. +func TestExecuteLadderRun_InvalidCoverageWithoutRateIsErrored(t *testing.T) { + store := &ladderTestStore{} + cap := &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5), + rates: map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: 0.5}, + layerStates: map[pkgladder.LayerType]pkgladder.LayerState{ + pkgladder.LayerConvertibleRI: {Layer: pkgladder.LayerConvertibleRI, ExistingUSDPerHour: f64(-3), ExpiringUSDPerHour: f64(0)}, + pkgladder.LayerEC2InstanceSP: spState(f64(0), f64(0)), + }} + err := runWithRates(t, store, cap) + require.ErrorContains(t, err, "non-negative") + require.ErrorContains(t, err, string(pkgladder.LayerConvertibleRI)) + assert.Nil(t, store.savedRun) +} diff --git a/known_issues/05_config_store_postgres.md b/known_issues/05_config_store_postgres.md index a301acdd..9c117732 100644 --- a/known_issues/05_config_store_postgres.md +++ b/known_issues/05_config_store_postgres.md @@ -129,9 +129,11 @@ **Implementation plan:** replace the SELECT+INSERT with a single atomic `INSERT ... ON CONFLICT (id) DO UPDATE SET count = CASE WHEN reset_time < $now THEN 1 ELSE count + 1 END, reset_time = CASE WHEN reset_time < $now THEN $newResetTime ELSE reset_time END, updated_at = $now RETURNING count, reset_time`. Drop the `queryErr/count/existingResetTime` arguments from `rateLimitOutcome`. Tests: `TestDBRateLimiter_ConcurrentFirstRequest` (20 goroutines hit same id, assert no SQLSTATE 23505 + final count = 20) and `TestDBRateLimiter_WindowExpiry_AtomicReset` (pre-seed past reset_time, single call resets count to 1). -## MEDIUM: nothing stops a future ladder executor from claiming a legacy on-demand row +## ~~MEDIUM: nothing stops a future ladder executor from claiming a legacy on-demand row~~ — RESOLVED **File**: `internal/config/store_postgres_ladder_lifecycle.go`, `ClaimDueLadderTranches` **Description**: Legacy `on_demand_equivalent` tranches net at rate 1 and the amend ceiling assumes NULL-basis rows are on-demand (#886 left them unknown). The claim query does not filter by basis, so an executor that buys `amount_usd_hr` as commitment dollars would overbuy a legacy row. No executor exists yet. **Impact**: None today; blocks executor enablement. Track on #946 and the executor issue. -**Status:** Open +**Status:** ✔️ Resolved + +**Resolved by:** the #946 fix. `selectDueLadderTranchesTx` (used by `ClaimDueLadderTranches`) now selects only `amount_basis = 'commitment'` rows, which the validated `ladder_tranche_amount_provenance` constraint pairs with a stored `commitment_rate`. NULL-basis and `on_demand_equivalent` rows are never claimed: they stay netted while within `LadderFireGrace`, then the overdue sweep cancels them and the next run re-plans the gap in commitment dollars. `TestClaimDueLadderTranches_NeverClaimsOnDemandOrUnknownBasisRows` covers this against real PostgreSQL, including a limit-1 claim where older legacy rows must not starve a due commitment row.