Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion internal/config/ladder_timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions internal/config/store_postgres_ladder_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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))
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions internal/config/store_postgres_ladder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions internal/config/store_postgres_ladder_timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package config
import (
"context"
"encoding/json"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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")
}
4 changes: 2 additions & 2 deletions internal/purchase/ladder_reconcile_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading