From aedb5ae7ad24c6a86d478d9c44c44275a223309d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 23:32:00 +0200 Subject: [PATCH] fix(ladder): persist immediate ramp steps as tranches and keep the plan display-only Bump cloud-commitments-go pkg and the providers to ad67030, where every ramp step including AfterDays==0 is a Tranche and TrancheResult.BuyNow is removed. - persistLadderRun already persists every returned Tranche; the immediate step now arrives as a row with scheduled_date = run start, so the next run's in-flight netting (grace-bounded, #824) sees it. - The plan JSON purchase action carries only the immediate step's amount, not the allocation gap; a layer with no immediate step has no purchase action. - total_hourly_commit is the sum of the persisted tranche rows. - Amounts are stored unchanged (on-demand-equivalent $/hr until go#349). No executor exists and flags stay off; the plan is not a purchase path. Refs #118 Refs go#36 Co-Authored-By: Claude Sonnet 5.5 --- go.mod | 8 +- go.sum | 16 +- internal/server/handler_ladder.go | 106 +++++--- ...ndler_ladder_immediate_integration_test.go | 237 ++++++++++++++++++ internal/server/handler_ladder_test.go | 61 +++-- 5 files changed, 373 insertions(+), 55 deletions(-) create mode 100644 internal/server/handler_ladder_immediate_integration_test.go diff --git a/go.mod b/go.mod index 3b8fff1b..e4e30722 100644 --- a/go.mod +++ b/go.mod @@ -84,10 +84,10 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 - github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012130811-9dae64b216d4 - github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012130811-9dae64b216d4 - github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261012130811-9dae64b216d4 - github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261012130811-9dae64b216d4 + github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012205319-ad67030387e4 + github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012205319-ad67030387e4 + github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261012205319-ad67030387e4 + github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261012205319-ad67030387e4 github.com/aws/aws-lambda-go v1.47.0 github.com/aws/aws-sdk-go-v2/service/kms v1.50.4 github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0 diff --git a/go.sum b/go.sum index a21fc96e..f7c47e3d 100644 --- a/go.sum +++ b/go.sum @@ -92,14 +92,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012130811-9dae64b216d4 h1:4C/4ZLemkgC45z47LtCjICglnfcTBzP2KUAq/gZEzZQ= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012130811-9dae64b216d4/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012130811-9dae64b216d4 h1:fnEv+2eCL0ObXn1nAFlBa2kZy1A0/nhc7LxYeDFCCkQ= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012130811-9dae64b216d4/go.mod h1:C0ildZeRds0aRvDZp49MLY0kFWh/4FiM4lPWWWd3HFA= -github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261012130811-9dae64b216d4 h1:HljKKZOmfam4C3T5109DMeUQuyxVHRohroA4j/OU5oY= -github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261012130811-9dae64b216d4/go.mod h1:nM3Zqd5N2mMIsNoTtnKF6RFyM1E3HVjAGIsXVx9b3bY= -github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261012130811-9dae64b216d4 h1:o9hnEg+Ro/RynW8ZWDVMC0E2yWt08izWeFbrCsZZx/c= -github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261012130811-9dae64b216d4/go.mod h1:uJ7UpLaUqu1fpfCEECuqjLXV+As2tibtPCapPF3AZys= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012205319-ad67030387e4 h1:cXoxVl0G696HrsXGc0t2jKH5iHUQe+i9pL/q4dnFPSc= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012205319-ad67030387e4/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012205319-ad67030387e4 h1:PCPhtB3yJsLdg6zitDgNxgntHGq3unUn0NoyV1u2PwY= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012205319-ad67030387e4/go.mod h1:fiu6WQmUGRdViiOf45QV235Cowq8j0yn8ahGWCGEFn8= +github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261012205319-ad67030387e4 h1:dbkTxvqS1xWTvCSKulDWL0UIuQSZ1JTBRw14OW/U+wU= +github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261012205319-ad67030387e4/go.mod h1:8vqO2ZlDzjX/Z4bESZVp/IenhTifkySeF0VJwQKihLA= +github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261012205319-ad67030387e4 h1:T3woCHq7rBuExSd0+BI1BYfF0Pd5AMgpngnc8Bqepd0= +github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261012205319-ad67030387e4/go.mod h1:J4H0b34EwQQ4p9cM3amsIU9tFmbzecS8E00ybjVp56g= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= diff --git a/internal/server/handler_ladder.go b/internal/server/handler_ladder.go index bf5ccca3..83fb2390 100644 --- a/internal/server/handler_ladder.go +++ b/internal/server/handler_ladder.go @@ -279,6 +279,10 @@ func (app *Application) processOneLadderConfig( // here: they are executed purchases already reflected in the engine's // ExistingUSDPerHour, so counting them again would double-subtract. // +// Units: tranche amounts are stored exactly as Allocate emits them, which is +// ON-DEMAND-EQUIVALENT $/hr until Allocate converts to commitment dollars +// (go#349). Do not convert here. +// // Persistence is APPEND-ONLY (see persistLadderRun): every run inserts its new // scheduled tranches and NEVER supersedes existing ones. Netting already // subtracted the config's existing scheduled tranches from the gap, so each run @@ -364,7 +368,14 @@ func (app *Application) executeLadderRun( } // Assemble LadderPlan (Q5: interleaves purchases AND reshapes in one JSON). - plan := assembleLadderPlan(scope, now, allocResult, layerStates, baseline, engineCfg.TargetCoveragePct, term, paymentOpt) + // The plan is display only: its purchase amounts are the immediate tranches + // that persistLadderRun writes, so it never authorizes more than the rows + // that netting sees. + immediate, err := immediateTrancheAmounts(trancheResult.Tranches, now) + if err != nil { + return err + } + plan := assembleLadderPlan(scope, now, allocResult, immediate, layerStates, baseline, engineCfg.TargetCoveragePct, term, paymentOpt) // Validate and persist the run row + tranche audit rows. L5 append-only: // SaveLadderRunWithTranches inserts the run and its NEW scheduled tranches @@ -403,27 +414,30 @@ func (app *Application) persistLadderRun( modeStr := dbCfg.Mode cadenceStr := dbCfg.Cadence runRow := &config.LadderRunDB{ - ID: runID, - ConfigID: &cfgID, - StartedAt: now, - Status: pkgladder.RunStatusPlanned, - Mode: &modeStr, - Cadence: &cadenceStr, - Plan: planJSON, - BaselineUSDHr: baseline.LowWaterUSDPerHour, - TargetUSDHr: ratToFloat64Ptr(plan.TargetUSDPerHour), - ExistingUSDHr: ratToFloat64Ptr(plan.ExistingUSDPerHour), - GapUSDHr: ratToFloat64Ptr(plan.GapUSDPerHour), - TotalHourlyCommit: allocTotalHourlyCommit(allocResult.Allocations), - } - - // Build the tranche audit rows (status=scheduled; no firing in PR-2). The - // run ID is known up front (runRow.ID), so tranches can reference it before - // the insert. + ID: runID, + ConfigID: &cfgID, + StartedAt: now, + Status: pkgladder.RunStatusPlanned, + Mode: &modeStr, + Cadence: &cadenceStr, + Plan: planJSON, + BaselineUSDHr: baseline.LowWaterUSDPerHour, + TargetUSDHr: ratToFloat64Ptr(plan.TargetUSDPerHour), + ExistingUSDHr: ratToFloat64Ptr(plan.ExistingUSDPerHour), + GapUSDHr: ratToFloat64Ptr(plan.GapUSDPerHour), + } + + // Build the tranche rows (status=scheduled; nothing fires them yet). EVERY + // ramp step is a row, including the immediate one (scheduled_date = run + // start), so in-flight netting sees it. The run ID is known up front + // (runRow.ID), so tranches can reference it before the insert. trancheRows, err := buildTrancheDBRows(trancheResult.Tranches, runRow.ID, &cfgID) if err != nil { return fmt.Errorf("buildTrancheDBRows: %w", err) } + // total_hourly_commit is what this run persists, not the allocation gap, so + // it always equals the sum of the rows netting will read back. + runRow.TotalHourlyCommit = sumTrancheRowAmounts(trancheRows) // L5 APPEND-ONLY: always persist via the plain path, which INSERTS the new // scheduled tranches and never supersedes upcoming ones (it only cancels @@ -530,7 +544,7 @@ func ladderConfigToEngine(dbCfg *config.LadderConfigDB, accountID string) (pkgla // assembleLadderPlan builds a LadderPlan from the Allocate result and the // collected read-side data. Q5: Actions interleaves both purchases -// (Allocations) and Reshapes in one authoritative plan JSON. Term and +// (immediate ramp steps only) and Reshapes in one display-only plan JSON. Term and // PaymentOption are stamped on every ActionPurchase entry; they are required // by PlannedAction.Validate for purchase actions. // @@ -548,6 +562,7 @@ func assembleLadderPlan( scope pkgladder.Scope, now time.Time, allocResult *pkgladder.AllocateResult, + immediate map[pkgladder.LayerType]*big.Rat, layerStates map[pkgladder.LayerType]pkgladder.LayerState, baseline pkgladder.UsageBaseline, targetCoveragePct float64, @@ -555,12 +570,20 @@ func assembleLadderPlan( paymentOpt pkgladder.PaymentOption, ) *pkgladder.LadderPlan { actions := make([]pkgladder.PlannedAction, 0, len(allocResult.Allocations)+len(allocResult.Reshapes)+len(allocResult.Holds)) - // Purchase allocations come first so approval-email ordering is stable. + // Purchase actions come first so approval-email ordering is stable. Each + // carries only the immediate ramp step for its layer, not the allocation + // gap: the later steps are persisted tranches, and a layer whose ramp has no + // immediate step has no purchase action. The plan is display only; the sole + // purchase path is tranche -> purchase_executions (not built yet). for _, alloc := range allocResult.Allocations { + amount := immediate[alloc.Layer] + if amount == nil { + continue + } actions = append(actions, pkgladder.PlannedAction{ Action: pkgladder.ActionPurchase, Layer: alloc.Layer, - AmountUSDPerHour: alloc.GapUSDPerHour, + AmountUSDPerHour: amount, Term: term, PaymentOption: paymentOpt, Rationale: alloc.Rationale, @@ -642,13 +665,40 @@ func sumAllocationGaps(allocs []pkgladder.Allocation) *big.Rat { return sum } -// allocTotalHourlyCommit sums the GapUSDPerHour across all allocations, -// converting to float64. This is the non-nullable total_hourly_commit stored -// in the run row; it starts at 0 for a no-op (Hold-only) plan, which is the -// correct semantic (zero new commitment, not "not computed"). -func allocTotalHourlyCommit(allocs []pkgladder.Allocation) float64 { - f, _ := sumAllocationGaps(allocs).Float64() - return f +// sumTrancheRowAmounts sums the amounts of the tranche rows a run persists. +// This is the non-nullable total_hourly_commit stored in the run row; it is 0 +// for a no-op (Hold-only) plan, which is the correct semantic (zero new +// commitment, not "not computed"). Units are whatever Allocate emits: +// on-demand-equivalent $/hr until the commitment-dollar conversion lands +// (go#349); this code stores amounts unchanged. +func sumTrancheRowAmounts(rows []config.LadderTrancheDB) float64 { + var sum float64 + for i := range rows { + sum += rows[i].AmountUSDHr + } + return sum +} + +// immediateTrancheAmounts sums, per layer, the tranches that fire at the run +// start (FireAfter <= now: the AfterDays==0 ramp step). It fails loud on a +// malformed amount, like buildTrancheDBRows. +func immediateTrancheAmounts(tranches []pkgladder.Tranche, now time.Time) (map[pkgladder.LayerType]*big.Rat, error) { + sums := make(map[pkgladder.LayerType]*big.Rat) + for i := range tranches { + tr := &tranches[i] + if tr.FireAfter.After(now) { + continue + } + r, ok := new(big.Rat).SetString(tr.AmountUSDPerHour) + if !ok { + return nil, fmt.Errorf("tranche %s: malformed amount_usd_per_hour %q (not a parseable rational)", tr.ID, tr.AmountUSDPerHour) + } + if sums[tr.Layer] == nil { + sums[tr.Layer] = new(big.Rat) + } + sums[tr.Layer].Add(sums[tr.Layer], r) + } + return sums, nil } // buildTrancheDBRows converts pkg/ladder Tranche rows to config.LadderTrancheDB diff --git a/internal/server/handler_ladder_immediate_integration_test.go b/internal/server/handler_ladder_immediate_integration_test.go new file mode 100644 index 00000000..94c34e73 --- /dev/null +++ b/internal/server/handler_ladder_immediate_integration_test.go @@ -0,0 +1,237 @@ +//go:build integration + +package server + +// Real PostgreSQL tests for ladder PR B1 (immediate ramp steps persisted as +// tranches). executeLadderRun, the concrete PostgresStore and the migrated +// schema are real; the ladder capability is a fixture (labeled FIXTURE). +// No purchase path exists: the executor is not built and nothing fires. +// +// Database: set LADDER_B1_TEST_DATABASE_URL to a THROWAWAY local database +// whose name starts with "ladder_b1_"; otherwise a Postgres container is used. + +import ( + "context" + "encoding/json" + "net/url" + "os" + "strconv" + "strings" + "testing" + "time" + + pkgladder "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/LeanerCloud/cloud-commitments-platform/internal/database" + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ladderB1Target = 10.0 // FIXTURE: low-water 12.5 * 80% coverage, existing 0 + +func ladderB1Conn(ctx context.Context, t *testing.T) *database.Connection { + t.Helper() + dsn := os.Getenv("LADDER_B1_TEST_DATABASE_URL") + if dsn == "" { + c := testhelpers.RequirePostgresContainer(ctx, t) + t.Cleanup(func() { _ = c.Cleanup(context.Background()) }) + require.NoError(t, migrations.RunMigrations(ctx, c.DB.Pool(), "../database/postgres/migrations", "", "")) + return c.DB + } + u, err := url.Parse(dsn) + require.NoError(t, err) + require.True(t, strings.HasPrefix(strings.TrimPrefix(u.Path, "/"), "ladder_b1_"), "refusing a non-throwaway database") + port, err := strconv.Atoi(u.Port()) + require.NoError(t, err) + pw, _ := u.User.Password() + conn, err := database.NewConnection(ctx, &database.Config{ + Host: u.Hostname(), Port: port, Database: strings.TrimPrefix(u.Path, "/"), + User: u.User.Username(), Password: pw, SSLMode: "disable", MaxConnections: 5, + HealthCheckPeriod: time.Minute, MaxConnLifetime: time.Hour, MaxConnIdleTime: 30 * time.Minute, + }, nil) + require.NoError(t, err) + t.Cleanup(conn.Close) + require.NoError(t, migrations.RunMigrations(ctx, conn.Pool(), "../database/postgres/migrations", "", "")) + return conn +} + +type ladderB1Env struct { + conn *database.Connection + app *Application + cfg *config.LadderConfigDB + cap *fakeLadderCapability + start time.Time +} + +// newLadderB1Env seeds a fresh account and config with the given ramp. The +// FIXTURE capability yields a single ConvertibleRI allocation of gap 10. +func newLadderB1Env(ctx context.Context, t *testing.T, conn *database.Connection, ramp []pkgladder.RampStep) *ladderB1Env { + t.Helper() + acct := uuid.New().String() + _, err := conn.Exec(ctx, `INSERT INTO cloud_accounts (id, name, enabled, provider, external_id, aws_is_org_root, created_at, updated_at) + VALUES ($1, 'b1', true, 'aws', $2, false, now(), now())`, acct, strconv.FormatInt(time.Now().UnixNano()%1e12, 10)) + require.NoError(t, err) + rampJSON, err := json.Marshal(pkgladder.RampSchedule{Steps: ramp}) + require.NoError(t, err) + store := config.NewPostgresStore(conn) + cfg := validTestDBConfig("") + cfg.CloudAccountID = acct + cfg.RampSchedule = rampJSON + saved, err := store.UpsertLadderConfig(ctx, &cfg) + require.NoError(t, err) + return &ladderB1Env{ + conn: conn, app: &Application{Config: store}, cfg: saved, + cap: &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5)}, + start: time.Now().UTC().Truncate(time.Microsecond), + } +} + +func (e *ladderB1Env) run(ctx context.Context, t *testing.T, now time.Time) string { + t.Helper() + require.NoError(t, e.app.executeLadderRun(ctx, e.cfg, e.cap, "123456789012", pkgladder.Term1Year, pkgladder.PaymentNoUpfront, now)) + var id string + require.NoError(t, e.conn.QueryRow(ctx, `SELECT id FROM ladder_runs WHERE config_id=$1 ORDER BY started_at DESC, created_at DESC LIMIT 1`, e.cfg.ID).Scan(&id)) + return id +} + +type b1Row struct { + amount float64 + at time.Time + status string +} + +func (e *ladderB1Env) rows(ctx context.Context, t *testing.T, runID string) []b1Row { + t.Helper() + rs, err := e.conn.Query(ctx, `SELECT amount_usd_hr::float8, scheduled_date, status FROM ladder_tranches WHERE run_id=$1 ORDER BY scheduled_date`, runID) + require.NoError(t, err) + defer rs.Close() + var out []b1Row + for rs.Next() { + var r b1Row + require.NoError(t, rs.Scan(&r.amount, &r.at, &r.status)) + out = append(out, r) + } + require.NoError(t, rs.Err()) + return out +} + +// live is the sum of rows that are not canceled: what could still be bought. +func (e *ladderB1Env) live(ctx context.Context, t *testing.T) float64 { + t.Helper() + var v float64 + require.NoError(t, e.conn.QueryRow(ctx, `SELECT COALESCE(SUM(amount_usd_hr),0)::float8 FROM ladder_tranches WHERE config_id=$1 AND status IN ('scheduled','fired')`, e.cfg.ID).Scan(&v)) + return v +} + +func (e *ladderB1Env) planAndTotal(ctx context.Context, t *testing.T, runID string) (ladderPlanJSONDTO, float64) { + t.Helper() + var raw []byte + var total float64 + require.NoError(t, e.conn.QueryRow(ctx, `SELECT plan, total_hourly_commit::float8 FROM ladder_runs WHERE id=$1`, runID).Scan(&raw, &total)) + var dto ladderPlanJSONDTO + require.NoError(t, json.Unmarshal(raw, &dto)) + return dto, total +} + +var ladderB1TwoStep = []pkgladder.RampStep{{AfterDays: 0, Fraction: 0.5}, {AfterDays: 30, Fraction: 0.5}} + +func TestLadderB1_ImmediateStepPersistedAndPlanMatchesRows(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + e := newLadderB1Env(ctx, t, ladderB1Conn(ctx, t), ladderB1TwoStep) + + runID := e.run(ctx, t, e.start) + rows := e.rows(ctx, t, runID) + require.Len(t, rows, 2) + assert.InDelta(t, 5.0, rows[0].amount, 1e-9) + assert.InDelta(t, 5.0, rows[1].amount, 1e-9) + assert.True(t, rows[0].at.Equal(e.start), "immediate tranche scheduled_date must be the run start") + assert.True(t, rows[1].at.Equal(e.start.AddDate(0, 0, 30))) + assert.Equal(t, "scheduled", rows[0].status) + + // Plan JSON is display only and agrees with the persisted rows: the one + // purchase action is the immediate step, total is the sum of both rows. + plan, total := e.planAndTotal(ctx, t, runID) + var purchases []ladderPlanJSONAction + for _, a := range plan.Actions { + if a.Action == string(pkgladder.ActionPurchase) { + purchases = append(purchases, a) + } + } + require.Len(t, purchases, 1) + require.NotNil(t, purchases[0].AmountUSDHr) + assert.InDelta(t, rows[0].amount, *purchases[0].AmountUSDHr, 1e-9) + assert.InDelta(t, rows[0].amount+rows[1].amount, total, 1e-9) + require.NotNil(t, plan.GapUSDHr) + assert.InDelta(t, ladderB1Target, *plan.GapUSDHr, 1e-9) +} + +func TestLadderB1_ExampleRampAfterDays0Fraction1_PersistsOneTranche(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + // The UI example ramp (frontend ladder.ts): after_days 0, fraction 1.0. + e := newLadderB1Env(ctx, t, ladderB1Conn(ctx, t), []pkgladder.RampStep{{AfterDays: 0, Fraction: 1.0}}) + runID := e.run(ctx, t, e.start) + rows := e.rows(ctx, t, runID) + require.Len(t, rows, 1) + assert.InDelta(t, ladderB1Target, rows[0].amount, 1e-9) + assert.True(t, rows[0].at.Equal(e.start)) + plan, total := e.planAndTotal(ctx, t, runID) + assert.InDelta(t, ladderB1Target, total, 1e-9) + require.Len(t, plan.Actions, 1) + assert.InDelta(t, ladderB1Target, *plan.Actions[0].AmountUSDHr, 1e-9) +} + +// Grace-boundary table: run 2 follows run 1 (ramp [0:.5,30:.5], gap 10) by +// `after`. The immediate row (scheduled_date = run 1 start) is netted while +// scheduled_date > run2 - 24h and canceled at <=; a boundary row is counted +// or canceled, never neither. Netted: run 2 plans nothing. Canceled: only +// the 5 that is no longer in flight is re-planned. +func TestLadderB1_NextRunNetsImmediateRowWithinGrace(t *testing.T) { + conn := ladderB1Conn(t.Context(), t) + cases := []struct { + name string + after time.Duration + wantNew float64 + wantImmediat string + }{ + {"1h", time.Hour, 0, string(pkgladder.TrancheStatusScheduled)}, + {"grace minus 1us", config.LadderFireGrace - time.Microsecond, 0, string(pkgladder.TrancheStatusScheduled)}, + {"exactly grace", config.LadderFireGrace, 5, string(pkgladder.TrancheStatusCancelled)}, + {"grace plus 1us", config.LadderFireGrace + time.Microsecond, 5, string(pkgladder.TrancheStatusCancelled)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + e := newLadderB1Env(ctx, t, conn, ladderB1TwoStep) + first := e.run(ctx, t, e.start) + second := e.run(ctx, t, e.start.Add(tc.after)) + + var sum float64 + for _, r := range e.rows(ctx, t, second) { + sum += r.amount + } + assert.InDelta(t, tc.wantNew, sum, 1e-9) + assert.Equal(t, tc.wantImmediat, e.rows(ctx, t, first)[0].status) + assert.LessOrEqual(t, e.live(ctx, t), ladderB1Target+1e-6) + }) + } +} + +// Fixed point: re-running daily for 40 days (nothing fires: no executor) never +// leaves more live commitment than target-E, and ends at the target. +func TestLadderB1_DailyRerunNeverExceedsTarget(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + e := newLadderB1Env(ctx, t, ladderB1Conn(ctx, t), ladderB1TwoStep) + for day := 0; day <= 40; day++ { + e.run(ctx, t, e.start.Add(time.Duration(day)*24*time.Hour)) + live := e.live(ctx, t) + require.LessOrEqual(t, live, ladderB1Target+1e-6, "day %d over-plans: live=%v", day, live) + } + assert.InDelta(t, ladderB1Target, e.live(ctx, t), 1e-6) +} diff --git a/internal/server/handler_ladder_test.go b/internal/server/handler_ladder_test.go index 7dd0003f..5285dda6 100644 --- a/internal/server/handler_ladder_test.go +++ b/internal/server/handler_ladder_test.go @@ -516,10 +516,8 @@ func TestExecuteLadderRun_HealthyRun(t *testing.T) { ctx := testutil.TestContext(t) now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) dbCfg := validTestDBConfig("cfg-healthy") - // Two-step ramp with a DELAYED step: an AfterDays==0 step becomes a BuyNow - // action, only AfterDays>0 steps become persisted tranches. This ensures - // SaveLadderTranches receives a non-empty batch so the assertion below - // exercises real tranche persistence. + // Two-step ramp: the AfterDays==0 step is persisted as a tranche too + // (scheduled_date = run start), alongside the delayed one. dbCfg.RampSchedule, _ = json.Marshal(pkgladder.RampSchedule{ Steps: []pkgladder.RampStep{ {AfterDays: 0, Fraction: 0.5}, @@ -588,6 +586,40 @@ func TestExecuteLadderRun_HealthyRun(t *testing.T) { // No panic means PurchaseLayer and ReshapeBuffer were not called. } +// TestExecuteLadderRun_ImmediateStepIsTrancheAndPlanShowsOnlyIt is a MOCK-store +// test (no database): the AfterDays==0 step is persisted as a tranche with +// scheduled_date = run start, the plan's purchase action carries only that +// step's amount, and total_hourly_commit is the sum of the persisted rows. +func TestExecuteLadderRun_ImmediateStepIsTrancheAndPlanShowsOnlyIt(t *testing.T) { + ctx := testutil.TestContext(t) + now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + dbCfg := validTestDBConfig("cfg-immediate") + dbCfg.RampSchedule, _ = json.Marshal(pkgladder.RampSchedule{Steps: []pkgladder.RampStep{ + {AfterDays: 0, Fraction: 0.25}, {AfterDays: 30, Fraction: 0.75}, + }}) + store := &ladderTestStore{} + app := &Application{Config: store} + cap := &fakeLadderCapability{t: t, baseline: testBaselineLowWater(12.5)} // target 10, one base allocation + + require.NoError(t, app.executeLadderRun(ctx, &dbCfg, cap, "123456789012", pkgladder.Term1Year, pkgladder.PaymentNoUpfront, now)) + + require.Len(t, store.savedTranches, 2) + assert.True(t, store.savedTranches[0].ScheduledDate.Equal(now), "immediate tranche scheduled_date must be the run start") + assert.InDelta(t, 2.5, store.savedTranches[0].AmountUSDHr, 1e-9) + assert.InDelta(t, 7.5, store.savedTranches[1].AmountUSDHr, 1e-9) + assert.InDelta(t, 10.0, store.savedRun.TotalHourlyCommit, 1e-9, "total must be the sum of persisted tranches") + + var planDTO ladderPlanJSONDTO + require.NoError(t, json.Unmarshal(store.savedRun.Plan, &planDTO)) + var amounts []float64 + for _, a := range planDTO.Actions { + if a.Action == string(pkgladder.ActionPurchase) { + amounts = append(amounts, *a.AmountUSDHr) + } + } + assert.Equal(t, []float64{2.5}, amounts, "the plan shows only the immediate step, not the full gap") +} + // ============================================================ // executeLadderRun: GetUsageBaseline ERROR -> no run persisted // ============================================================ @@ -724,9 +756,8 @@ func TestExecuteLadderRun_MaxActionsExceeded_TruncatesAndPersists(t *testing.T) now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) dbCfg := validTestDBConfig("cfg-maxactions") dbCfg.MaxActionsPerRun = 1 // base+flex split yields 2 allocations > 1 - // A future-only ramp step so the kept allocation lands as a scheduled - // tranche row (an AfterDays==0 step becomes a BuyNow action instead, - // which produces no ladder_tranches rows). + // A future-only ramp step: the kept allocation lands as one scheduled + // tranche row and the plan has no immediate purchase action. rampJSON, err := json.Marshal(pkgladder.RampSchedule{ Steps: []pkgladder.RampStep{{AfterDays: 30, Fraction: 1.0}}, }) @@ -760,9 +791,10 @@ func TestExecuteLadderRun_MaxActionsExceeded_TruncatesAndPersists(t *testing.T) var planDTO ladderPlanJSONDTO require.NoError(t, json.Unmarshal(run.Plan, &planDTO)) - // Exactly ONE purchase survives: the larger-gap flex allocation ($70/hr on + // Exactly ONE allocation survives: the larger-gap flex allocation ($70/hr on // EC2InstanceSP, the fake's RoleFlex layer); the $10/hr base allocation is - // dropped. At least one hold must record the truncation for audit. + // dropped. At least one hold must record the truncation for audit. The + // ramp has no immediate step, so the display-only plan lists no purchase. var purchases []ladderPlanJSONAction var truncHolds []ladderPlanJSONAction for _, a := range planDTO.Actions { @@ -775,11 +807,7 @@ func TestExecuteLadderRun_MaxActionsExceeded_TruncatesAndPersists(t *testing.T) } } } - require.Len(t, purchases, 1, "exactly MaxActionsPerRun purchase actions must survive") - assert.Equal(t, string(pkgladder.LayerEC2InstanceSP), purchases[0].Layer, - "the largest-gap (flex, $70/hr) allocation must be the survivor") - require.NotNil(t, purchases[0].AmountUSDHr) - assert.InDelta(t, 70.0, *purchases[0].AmountUSDHr, 1e-9) + assert.Empty(t, purchases, "a future-only ramp has no immediate purchase to display") require.NotEmpty(t, truncHolds, "each dropped allocation must leave a max_actions_per_run hold") assert.Contains(t, truncHolds[0].Rationale, "deferred to a later run") @@ -789,7 +817,10 @@ func TestExecuteLadderRun_MaxActionsExceeded_TruncatesAndPersists(t *testing.T) "total_hourly_commit must reflect the truncated plan only") // Tranches exist for the kept allocation. - assert.NotEmpty(t, store.savedTranches, "the kept allocation must produce tranches") + require.Len(t, store.savedTranches, 1, "the kept allocation must produce one tranche") + assert.Equal(t, pkgladder.LayerEC2InstanceSP, store.savedTranches[0].LayerType, + "the largest-gap (flex, $70/hr) allocation must be the survivor") + assert.InDelta(t, 70.0, store.savedTranches[0].AmountUSDHr, 1e-9) } // ============================================================