From 6af64138035bec57a46d37dc915b68eebe72085f Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Thu, 22 Oct 2026 01:36:54 +0100 Subject: [PATCH 1/6] fix(ladder): convert existing coverage to on-demand-equivalent dollars and never claim legacy tranches The AWS provider reports LayerState.ExistingUSDPerHour and ExpiringUSDPerHour in commitment dollars (SavingsPlan.Commitment for the SP layers, amortized cost for the RI layer), but Allocate compares them with an on-demand-equivalent target. The planner passed them through unconverted, so existing coverage was understated by the commitment rate and the gap overstated: a 2.5 commitment $/hr EC2 Instance SP at rate 0.5 was netted as 2.5 OD instead of 5. executeLadderRun now converts each layer with od = commitment / rate, using the GetCommitmentRates rate the run already fetched (option 2 on the issue). Existing rounds up and expiring down, so net coverage is never understated. A layer with non-zero existing or expiring commitment and no rate fails the run instead of being netted 1:1. ladder_runs.existing_usd_hr and the plan JSON existing are now on-demand-equivalent. Per the maintainer's note on the issue, ClaimDueLadderTranches now claims only amount_basis = 'commitment' rows. Legacy NULL-basis and on_demand_equivalent rows stay netted until the overdue sweep cancels them, so an executor can never buy their OD amount as commitment dollars. Closes #946 Co-Authored-By: Claude Opus 5.5 --- ...ladder_reconcile_retry_integration_test.go | 3 +- ...s_ladder_amend_ceiling_integration_test.go | 3 + .../config/store_postgres_ladder_lifecycle.go | 8 + ...tgres_ladder_lifecycle_integration_test.go | 82 ++++++-- ...e_postgres_ladder_lock_integration_test.go | 6 +- ...ostgres_ladder_resolve_integration_test.go | 2 +- internal/config/store_postgres_ladder_test.go | 17 +- .../ladder_reconcile_integration_test.go | 4 +- internal/server/handler_ladder.go | 19 +- internal/server/ladder_existing_units.go | 131 +++++++++++++ internal/server/ladder_existing_units_test.go | 180 ++++++++++++++++++ known_issues/05_config_store_postgres.md | 6 +- 12 files changed, 431 insertions(+), 30 deletions(-) create mode 100644 internal/server/ladder_existing_units.go create mode 100644 internal/server/ladder_existing_units_test.go diff --git a/internal/api/handler_ladder_reconcile_retry_integration_test.go b/internal/api/handler_ladder_reconcile_retry_integration_test.go index 938950768..881c2e72a 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/store_postgres_ladder_amend_ceiling_integration_test.go b/internal/config/store_postgres_ladder_amend_ceiling_integration_test.go index b6134dde6..4b841e4eb 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 3c4bf1d28..7fd703640 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 2d74ba025..ae237fff8 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 92546b6be..36ef41cdb 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_resolve_integration_test.go b/internal/config/store_postgres_ladder_resolve_integration_test.go index b16b9d966..c1cf301bf 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 814747ebc..0fa027dad 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/purchase/ladder_reconcile_integration_test.go b/internal/purchase/ladder_reconcile_integration_test.go index 36cbe15a1..595cd2740 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 163381334..b365c5fbc 100644 --- a/internal/server/handler_ladder.go +++ b/internal/server/handler_ladder.go @@ -278,8 +278,10 @@ 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 +// 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). 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. // @@ -329,6 +331,13 @@ 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). + layerStates, 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 @@ -553,7 +562,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. // @@ -597,7 +607,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 { diff --git a/internal/server/ladder_existing_units.go b/internal/server/ladder_existing_units.go new file mode 100644 index 000000000..1588cade2 --- /dev/null +++ b/internal/server/ladder_existing_units.go @@ -0,0 +1,131 @@ +package server + +import ( + "errors" + "fmt" + "math" + "math/big" + + pkgladder "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" +) + +// 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 run fails instead of guessing. + +// errNoRateForExistingCoverage marks a layer whose existing commitment cannot +// be converted to on-demand-equivalent dollars. +var errNoRateForExistingCoverage = errors.New("no commitment rate to convert existing commitment dollars to on-demand-equivalent dollars") + +// 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. +func ladderLayerStatesOnDemand( + states map[pkgladder.LayerType]pkgladder.LayerState, + rates map[pkgladder.LayerType]float64, +) (map[pkgladder.LayerType]pkgladder.LayerState, error) { + out := make(map[pkgladder.LayerType]pkgladder.LayerState, len(states)) + for layer, st := range states { + rate, hasRate := rates[layer] + od, err := layerStateOnDemand(st, rate, hasRate) + if err != nil { + return nil, fmt.Errorf("layer %s: %w", layer, err) + } + out[layer] = od + } + return out, nil +} + +// 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, hasRate bool) (pkgladder.LayerState, error) { + if isZeroOrNil(st.ExistingUSDPerHour) && isZeroOrNil(st.ExpiringUSDPerHour) { + return st, nil + } + if !hasRate { + return st, fmt.Errorf("%w (existing %s, expiring %s commitment USD/hr); refusing to assume 1:1", + errNoRateForExistingCoverage, fmtFloatPtr(st.ExistingUSDPerHour), fmtFloatPtr(st.ExpiringUSDPerHour)) + } + 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 } + +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 +} diff --git a/internal/server/ladder_existing_units_test.go b/internal/server/ladder_existing_units_test.go new file mode 100644 index 000000000..203e07e67 --- /dev/null +++ b/internal/server/ladder_existing_units_test.go @@ -0,0 +1,180 @@ +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, err := ladderLayerStatesOnDemand(states, map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: tc.rate}) + require.NoError(t, err) + 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, err := ladderLayerStatesOnDemand(states, map[pkgladder.LayerType]float64{}) + require.NoError(t, err) + 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 + }{ + {"existing without a rate", spState(f64(5), f64(0)), map[pkgladder.LayerType]float64{pkgladder.LayerComputeSP: 0.5}, "no commitment rate"}, + {"expiring without a rate", spState(f64(0), f64(1)), nil, "no commitment rate"}, + {"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 commitment on a layer with no rate cannot be converted; the run +// fails and persists nothing instead of netting it 1:1 and over-buying. +func TestExecuteLadderRun_ExistingCoverageWithoutRateIsErrored(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, "no commitment rate") + require.ErrorContains(t, err, string(pkgladder.LayerConvertibleRI)) + assert.Nil(t, store.savedRun) + assert.Empty(t, store.savedTranches) +} diff --git a/known_issues/05_config_store_postgres.md b/known_issues/05_config_store_postgres.md index a301acddc..9c1177320 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. From 14e41c558df0e01449ba63e6f2730b8f8c94dfde Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 26 Oct 2026 04:30:20 +0000 Subject: [PATCH 2/6] test(config): seed claimable rows in the provider precision round-trip test After merging main, TestLadderTrancheAmount_RoundTripsProviderPrecisionContract (#1199) claimed 0 of its 19 rows: it seeded NULL-basis tranches, which this branch makes unclaimable. Seed commitment-basis rows at rate 1 through claimableTranche instead, so the claim path still reads every amount back. The assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../config/store_postgres_ladder_precision_integration_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/config/store_postgres_ladder_precision_integration_test.go b/internal/config/store_postgres_ladder_precision_integration_test.go index b9400ceba..0ea6061bc 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) } From c73f31a90fa8f50646721cf3ef3feac4150c3c24 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 26 Oct 2026 04:38:32 +0000 Subject: [PATCH 3/6] fix(ladder): hold, not error, when existing coverage has no discount rate cloud-commitments-go only returns a discount rate for EC2 Instance SPs, so accounts holding Compute SPs or Convertible RIs (and EC2 Instance SP holders whenever Cost Explorer returns no rate) cannot have their existing coverage converted to on-demand-equivalent dollars. Failing those runs made every tick Errored and stopped ladder planning for most AWS customers. Owner decision D3: persist the run as a Hold that buys nothing and says why. ladderLayerStatesOnDemand now reports every layer that holds commitment but has no rate, and executeLadderRun persists a Hold-only run with plan reason_code "missing_discount_rate" and one Hold per layer whose rationale starts "Can't size this yet because". Allocate never runs, so no rate is guessed or netted 1:1; existing and gap stay NULL, which also keeps the amend ceiling from allowing raises against that run. The run counts as planned and logs one WARN line, not an error. Invalid rates and negative or non-finite amounts still fail the run. Target-met and other Allocate Holds are unchanged and carry no reason_code. Co-Authored-By: Claude Opus 5.5 --- internal/server/handler_ladder.go | 83 ++++++++++--- internal/server/ladder_existing_units.go | 112 +++++++++++++++--- internal/server/ladder_existing_units_test.go | 48 ++++---- 3 files changed, 190 insertions(+), 53 deletions(-) diff --git a/internal/server/handler_ladder.go b/internal/server/handler_ladder.go index 9a944f961..302da0bee 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" ) @@ -279,7 +280,10 @@ func (app *Application) processOneLadderConfig( // // 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). Allocate sizes the gap in +// 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. @@ -332,7 +336,9 @@ func (app *Application) executeLadderRun( // Providers report existing coverage in commitment dollars; Allocate and // the plan snapshot compare it with an on-demand-equivalent target (#946). - layerStates, err = ladderLayerStatesOnDemand(layerStates, rates) + // 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) } @@ -348,14 +354,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 } @@ -372,10 +377,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)) @@ -442,7 +481,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) } @@ -618,13 +657,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. @@ -642,6 +677,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 @@ -802,6 +851,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"` @@ -813,8 +865,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{ @@ -829,6 +881,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 index 1588cade2..3493d1c63 100644 --- a/internal/server/ladder_existing_units.go +++ b/internal/server/ladder_existing_units.go @@ -1,12 +1,16 @@ package server import ( - "errors" "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). @@ -34,42 +38,70 @@ import ( // // 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 run fails instead of guessing. +// 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). -// errNoRateForExistingCoverage marks a layer whose existing commitment cannot -// be converted to on-demand-equivalent dollars. -var errNoRateForExistingCoverage = errors.New("no commitment rate to convert existing commitment dollars to on-demand-equivalent dollars") +// 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, error) { +) (map[pkgladder.LayerType]pkgladder.LayerState, []ladderMissingRate, error) { out := make(map[pkgladder.LayerType]pkgladder.LayerState, len(states)) - for layer, st := range states { + var missing []ladderMissingRate + for _, layer := range slices.Sorted(maps.Keys(states)) { + st := states[layer] rate, hasRate := rates[layer] - od, err := layerStateOnDemand(st, rate, hasRate) + 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, fmt.Errorf("layer %s: %w", layer, err) + return nil, nil, fmt.Errorf("layer %s: %w", layer, err) } out[layer] = od } - return out, nil + 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, hasRate bool) (pkgladder.LayerState, error) { - if isZeroOrNil(st.ExistingUSDPerHour) && isZeroOrNil(st.ExpiringUSDPerHour) { +func layerStateOnDemand(st pkgladder.LayerState, rate float64) (pkgladder.LayerState, error) { + if !holdsCommitment(st) { return st, nil } - if !hasRate { - return st, fmt.Errorf("%w (existing %s, expiring %s commitment USD/hr); refusing to assume 1:1", - errNoRateForExistingCoverage, fmtFloatPtr(st.ExistingUSDPerHour), fmtFloatPtr(st.ExpiringUSDPerHour)) - } r, err := commitmentRateRat(rate) if err != nil { return st, err @@ -88,6 +120,12 @@ func layerStateOnDemand(st pkgladder.LayerState, rate float64, hasRate bool) (pk 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" @@ -129,3 +167,45 @@ func commitmentToOnDemand(name string, commitment *float64, rate *big.Rat, round } 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 index 203e07e67..c38864396 100644 --- a/internal/server/ladder_existing_units_test.go +++ b/internal/server/ladder_existing_units_test.go @@ -38,8 +38,9 @@ func TestLadderLayerStatesOnDemand_PinsCommitmentToOnDemandConversion(t *testing states := map[pkgladder.LayerType]pkgladder.LayerState{ pkgladder.LayerEC2InstanceSP: spState(f64(tc.existing), f64(tc.expiring)), } - out, err := ladderLayerStatesOnDemand(states, map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: tc.rate}) + 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") @@ -56,7 +57,7 @@ func TestLadderLayerStatesOnDemand_RoundsTowardUnderBuying(t *testing.T) { states := map[pkgladder.LayerType]pkgladder.LayerState{ pkgladder.LayerEC2InstanceSP: spState(f64(1), f64(1)), } - out, err := ladderLayerStatesOnDemand(states, map[pkgladder.LayerType]float64{pkgladder.LayerEC2InstanceSP: rate}) + 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)) @@ -72,7 +73,7 @@ func TestLadderLayerStatesOnDemand_RoundsTowardUnderBuying(t *testing.T) { 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}) + _, _, 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) @@ -86,8 +87,9 @@ func TestLadderLayerStatesOnDemand_EmptyLayersNeedNoRate(t *testing.T) { pkgladder.LayerComputeSP: {Layer: pkgladder.LayerComputeSP, ExistingUSDPerHour: f64(0), ExpiringUSDPerHour: f64(0)}, pkgladder.LayerConvertibleRI: {Layer: pkgladder.LayerConvertibleRI}, } - out, err := ladderLayerStatesOnDemand(states, map[pkgladder.LayerType]float64{}) + 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) @@ -100,8 +102,8 @@ func TestLadderLayerStatesOnDemand_RejectsUnconvertibleCoverage(t *testing.T) { rates map[pkgladder.LayerType]float64 wantErr string }{ - {"existing without a rate", spState(f64(5), f64(0)), map[pkgladder.LayerType]float64{pkgladder.LayerComputeSP: 0.5}, "no commitment rate"}, - {"expiring without a rate", spState(f64(0), f64(1)), nil, "no commitment rate"}, + {"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]"}, @@ -111,7 +113,7 @@ func TestLadderLayerStatesOnDemand_RejectsUnconvertibleCoverage(t *testing.T) { } 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) + _, _, 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)) }) @@ -162,19 +164,21 @@ func TestExecuteLadderRun_ExpiringCoverageIsOnDemandEquivalent(t *testing.T) { assert.InDelta(t, 3.0, store.savedRun.TotalHourlyCommit, 1e-9) } -// Existing commitment on a layer with no rate cannot be converted; the run -// fails and persists nothing instead of netting it 1:1 and over-buying. -func TestExecuteLadderRun_ExistingCoverageWithoutRateIsErrored(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, "no commitment rate") - require.ErrorContains(t, err, string(pkgladder.LayerConvertibleRI)) - assert.Nil(t, store.savedRun) - assert.Empty(t, store.savedTranches) +// 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) } From d72810f3e2b40d8573787978fb37ace3f278a3f3 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 26 Oct 2026 04:38:32 +0000 Subject: [PATCH 4/6] test(ladder): pin the missing-discount-rate Hold per layer kind Covers executeLadderRun end to end for each AWS layer kind holding coverage with no rate (Compute SP, Convertible RI, EC2 Instance SP): the run is persisted with no tranches, NULL existing and gap, reason_code missing_discount_rate and a "Can't size this yet" Hold naming the layer. A genuinely met target must still produce the ordinary "target already met" Hold with no reason_code, and bad amounts on a rate-less layer must still fail the run. The first three fail on the previous fail-loud head. Co-Authored-By: Claude Opus 5.5 --- .../server/ladder_missing_rate_hold_test.go | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 internal/server/ladder_missing_rate_hold_test.go 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 000000000..004c44110 --- /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) +} From 8d8619bc02fe80c45c95e6f0e9e407e585243bae Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 26 Oct 2026 04:38:32 +0000 Subject: [PATCH 5/6] feat(ladder): expose the run reason_code on the ladder runs API The ladder timeline already shows each action's rationale, so the UI tells a missing-rate Hold ("Can't size this yet because ...") from a target-met Hold ("target already met: ...") without a frontend change. Projecting the plan's reason_code onto /api/ladder/runs gives API clients the same distinction in machine-readable form. Runs without one omit the field. Co-Authored-By: Claude Opus 5.5 --- internal/config/ladder_timeline.go | 6 +++- .../config/store_postgres_ladder_timeline.go | 1 + ...stgres_ladder_timeline_integration_test.go | 33 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/config/ladder_timeline.go b/internal/config/ladder_timeline.go index 8ec990ac3..7b7576172 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_timeline.go b/internal/config/store_postgres_ladder_timeline.go index 792ba0b40..f560077be 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 1302e5af2..0a7a66b93 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") +} From 9b19af309d1389c70bc18004c702f840993b6b09 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 26 Oct 2026 04:42:15 +0000 Subject: [PATCH 6/6] style(ladder): rewrap the overlong executeLadderRun doc line Both reviews flagged the reflowed comment line at handler_ladder.go:284 as over 100 columns. Comment only. Co-Authored-By: Claude Opus 5.5 --- internal/server/handler_ladder.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/server/handler_ladder.go b/internal/server/handler_ladder.go index 302da0bee..c2e17a53b 100644 --- a/internal/server/handler_ladder.go +++ b/internal/server/handler_ladder.go @@ -284,9 +284,10 @@ func (app *Application) processOneLadderConfig( // 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. +// 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