Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions internal/config/ladder_provenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"math"
"math/big"
"strconv"
"strings"

"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
)
Expand Down Expand Up @@ -173,3 +175,55 @@ func LadderInFlightOnDemand(rows []LadderInFlightRow) (float64, error) {
}
return nettingFloat(total)
}

// ladderAmountScale is the fractional-digit scale of
// ladder_tranches.amount_usd_hr (NUMERIC(20,6), migration 000081).
const ladderAmountScale = 6

// LadderAmountFloor floors an exact planned tranche amount to the six decimal
// places ladder_tranches.amount_usd_hr stores, returning the exact floored
// decimal. A positive amount below 0.000001 floors to zero; callers must not
// persist that as a $0 tranche.
func LadderAmountFloor(amount *big.Rat) *big.Rat {
scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(ladderAmountScale), nil)
floored := new(big.Int).Mul(amount.Num(), scale)
floored.Div(floored, amount.Denom()) // Euclidean division: floor for the positive denominator
return new(big.Rat).SetFrac(floored, scale)
}

// LadderAmountFloat converts an exact planned tranche amount to the float64 a
// tranche row stores, flooring it to the column's six decimal places (see
// LadderAmountFloor). The go precision contract (pkg/ladder
// LadderCapability.PurchaseLayer, go#354) floors provider commitments and
// forbids callers rounding a *big.Rat up on the way to float64; nearest-float
// conversion can do exactly that just below a decimal boundary. Flooring at six
// decimals keeps a provider's five-decimal truncation identical to truncating
// the exact amount and keeps an amount below a provider minimum below it.
//
// Below 1e9 USD/hr the floored value has at most 15 significant digits, so the
// result's shortest decimal form is the floored value itself and the store
// writes it without any further rounding. Above about 1e9 USD/hr the nearest
// float64 of the floored decimal can round it up by less than one float64 ulp,
// and nothing here refuses that; it is unreachable under the provider's 1e6
// USD/hr cap.
func LadderAmountFloat(amount *big.Rat) float64 {
f, _ := LadderAmountFloor(amount).Float64()
return f
}

// ladderAmountText is the exact decimal insertLadderTranchesTx writes for a
// tranche amount. Sent as a float, the amount's shortest decimal would be
// rounded half-up to the column's scale by Postgres, which can carry it across
// a provider's five-decimal truncation boundary or up to a provider minimum.
// An amount the column cannot hold exactly is refused instead, so every stored
// amount reads back as the same float64.
func ladderAmountText(amount float64) (string, error) {
if math.IsNaN(amount) || math.IsInf(amount, 0) {
return "", fmt.Errorf("amount_usd_hr must be finite, got %v", amount)
}
text := strconv.FormatFloat(amount, 'f', -1, 64)
if _, fraction, ok := strings.Cut(text, "."); ok && len(fraction) > ladderAmountScale {
return "", fmt.Errorf("amount_usd_hr %s has more than %d decimal places and would be rounded by the column; floor it with LadderAmountFloat", text, ladderAmountScale)
}
return text, nil
}
82 changes: 82 additions & 0 deletions internal/config/ladder_provenance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"math"
"math/big"
"strconv"
"strings"
"testing"

Expand Down Expand Up @@ -109,3 +110,84 @@ func TestNewLadderInFlightRow_FloatFieldsFeedAllocate(t *testing.T) {
require.NoError(t, err)
require.Equal(t, math.SmallestNonzeroFloat64, row.Rate, "a rate below float range must not become 0")
}

// spCommitmentWire mirrors cloud-commitments-go
// providers/aws/internal/spcommitment.Normalize at the version go.mod pins
// (go#403, contract at pkg/ladder/capability.go): refuse an amount outside
// [0.001, 1000000] USD/hr, else truncate its shortest decimal to five places.
// ok=false is a refusal; the returned rat is the submitted commitment.
func spCommitmentWire(amount float64) (*big.Rat, bool) {
if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0.001 || amount > 1000000 {
return nil, false
}
whole, fraction, _ := strings.Cut(strconv.FormatFloat(amount, 'f', -1, 64), ".")
if len(fraction) > 5 {
fraction = fraction[:5]
}
wire, _ := new(big.Rat).SetString(whole + "." + fraction + "0")
return wire, true
}

// spCommitmentWireExact is the commitment the contract would submit for the
// exact planned amount: the same range check, then a floor to five places.
func spCommitmentWireExact(amount *big.Rat) (*big.Rat, bool) {
if amount.Cmp(big.NewRat(1, 1000)) < 0 || amount.Cmp(big.NewRat(1000000, 1)) > 0 {
return nil, false
}
scaled := new(big.Int).Mul(amount.Num(), big.NewInt(100000))
return new(big.Rat).SetFrac(scaled.Div(scaled, amount.Denom()), big.NewInt(100000)), true
}

func requireSameWire(t *testing.T, want *big.Rat, wantOK bool, got *big.Rat, gotOK bool, msg string) {
t.Helper()
require.Equal(t, wantOK, gotOK, "%s: provider refusal must not change", msg)
if wantOK {
require.Zero(t, want.Cmp(got), "%s: submitted commitment %s, want %s", msg, got.FloatString(6), want.FloatString(6))
}
}

// Issue #1182: a planned amount is floored to the six-decimal column, never
// rounded up, so the provider submits what it would for the exact amount.
func TestLadderAmountFloat_FloorsToStoredScaleWithoutRoundingUp(t *testing.T) {
for _, tc := range []struct{ planned, want string }{
{"1/3", "0.333333"},
{"12345999999999999/100000000000000000", "0.123459"}, // nearest float64 is 0.12346
{"1234567/10000000", "0.123456"},
{"9995/10000000", "0.000999"}, // half-up would reach the 0.001 minimum
{"9999999999999/10000000", "999999.999999"}, // half-up would reach 1000000
{"99999999999/100000", "999999.99999"},
{"1000000", "1000000"},
{"1/10", "0.1"},
{"1/1000", "0.001"},
{"1/10000000", "0"},
} {
planned, ok := new(big.Rat).SetString(tc.planned)
require.True(t, ok, tc.planned)
got := LadderAmountFloat(planned)
text, err := ladderAmountText(got)
require.NoError(t, err, tc.planned)
require.Equal(t, tc.want, text, tc.planned)
stored, _ := new(big.Rat).SetString(text)
require.LessOrEqual(t, stored.Cmp(planned), 0, "%s: stored amount must not exceed the plan", tc.planned)
want, wantOK := spCommitmentWireExact(planned)
wire, wireOK := spCommitmentWire(got)
requireSameWire(t, want, wantOK, wire, wireOK, tc.planned)
}
}

// Issue #1182: the store refuses an amount the NUMERIC(20,6) column would
// round rather than letting Postgres round it half-up.
func TestLadderAmountText_RefusesAmountsTheColumnWouldRound(t *testing.T) {
for amount, want := range map[float64]string{
0.001: "0.001", 1000000: "1000000", 999999.99999: "999999.99999",
999999.999999: "999999.999999", 0.1: "0.1", 0.29: "0.29", 12.5: "12.5",
} {
got, err := ladderAmountText(amount)
require.NoError(t, err)
require.Equal(t, want, got)
}
for _, amount := range []float64{0.1234599, 0.30000000000000004 /* float64 0.1+0.2 */, 1.0 / 3, 0.0009995, 999999.9999999, math.NaN(), math.Inf(1), math.Inf(-1)} {
_, err := ladderAmountText(amount)
require.Error(t, err, "%v", amount)
}
}
8 changes: 6 additions & 2 deletions internal/config/store_postgres_ladder.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,18 +297,22 @@ func insertLadderTranchesTx(ctx context.Context, tx pgx.Tx, tranches []LadderTra
if err != nil {
return fmt.Errorf("ladder_tranche %s: %w", tr.ID, err)
}
amount, err := ladderAmountText(tr.AmountUSDHr)
if err != nil {
return fmt.Errorf("ladder_tranche %s: %w", tr.ID, err)
}
_, err = tx.Exec(ctx, `
INSERT INTO ladder_tranches (
id, config_id, run_id, layer_type, amount_usd_hr,
term, payment_option, scheduled_date, status, execution_id,
settled_at, amount_basis, commitment_rate, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW())
) VALUES ($1, $2, $3, $4, $5::numeric, $6, $7, $8, $9, $10, $11, $12, $13, NOW())
`,
tr.ID,
tr.ConfigID,
tr.RunID,
string(tr.LayerType),
tr.AmountUSDHr,
amount,
string(tr.Term),
string(tr.PaymentOption),
tr.ScheduledDate,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//go:build integration

package config

import (
"context"
"math"
"math/big"
"sync/atomic"
"testing"
"time"

"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
"github.com/stretchr/testify/require"
)

// Issue #1182: tranche amounts are float64 stored in NUMERIC(20,6). Against a
// real Postgres, every amount the store accepts must read back as the same
// float64 through the claim path a tranche executor will use, so the go
// precision contract (range check, then five-decimal truncation; see
// spCommitmentWire) gives the same answer for the stored amount as for the
// written one, and for planner amounts, as for the exact plan.
func TestLadderTrancheAmount_RoundTripsProviderPrecisionContract(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
store := setupLadderStore(ctx, t)
cfgID := seedLadderConfig(ctx, t, store)
asOf := time.Now().UTC().Truncate(time.Microsecond)
due := asOf.Add(-time.Minute)

written := map[string]float64{}
planned := map[string]*big.Rat{}
var rows []LadderTrancheDB
add := func(amount float64, plan *big.Rat) {
tr := lifecycleTranche(cfgID, amount, ladder.TrancheStatusScheduled, due)
written[tr.ID], planned[tr.ID] = amount, plan
rows = append(rows, tr)
}
// Contract edges the column holds exactly, including values float64
// cannot represent exactly (0.1, 0.29, 999999.99999) and the range ends.
for _, amount := range []float64{
0.001, 0.00101, 0.000999, 0.1, 0.29, 0.12345, 1.00001, 123456.78901,
999999.99999, 999999.999999, 1000000, 1000000.000001, 99999999.999999,
} {
add(amount, nil)
}
// Exact planner amounts finer than the column, floored by LadderAmountFloat.
for _, text := range []string{
"1/3", "12345999999999999/100000000000000000", "1234567/10000000",
"9995/10000000", "9999999999999/10000000", "1000000",
} {
plan, ok := new(big.Rat).SetString(text)
require.True(t, ok, text)
add(LadderAmountFloat(plan), plan)
}
require.NoError(t, store.SaveLadderTranches(ctx, rows))

var calls atomic.Int64
claimed, err := store.ClaimDueLadderTranches(ctx, cfgID, asOf, len(rows), newExecCreator(ctx, t, store, &calls))
require.NoError(t, err)
require.Len(t, claimed, len(rows))
for i := range claimed {
tr := &claimed[i]
amount, ok := written[tr.ID]
require.True(t, ok, tr.ID)
require.Equal(t, math.Float64bits(amount), math.Float64bits(tr.AmountUSDHr), "stored %v read back as %v", amount, tr.AmountUSDHr)
var text string
require.NoError(t, store.db.QueryRow(ctx, `SELECT amount_usd_hr::text FROM ladder_tranches WHERE id = $1`, tr.ID).Scan(&text))
stored, _ := new(big.Rat).SetString(text)
wantText, err := ladderAmountText(amount)
require.NoError(t, err)
decimal, _ := new(big.Rat).SetString(wantText)
require.Zero(t, decimal.Cmp(stored), "column holds %s for %s", text, wantText)

want, wantOK := spCommitmentWire(amount)
if plan := planned[tr.ID]; plan != nil {
require.LessOrEqual(t, stored.Cmp(plan), 0, "stored %s exceeds planned %s", text, plan.RatString())
want, wantOK = spCommitmentWireExact(plan)
}
got, gotOK := spCommitmentWire(tr.AmountUSDHr)
requireSameWire(t, want, wantOK, got, gotOK, text)
}

// An amount the column would round half-up is refused, and its batch
// rolls back, instead of being stored as a different amount.
for _, amount := range []float64{0.1234599, 0.0009995, 999999.9999999, 1.0 / 3, math.NaN()} {
first := lifecycleTranche(cfgID, 1, ladder.TrancheStatusScheduled, due)
bad := lifecycleTranche(cfgID, amount, ladder.TrancheStatusScheduled, due)
require.Error(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{first, bad}), "%v", amount)
var count int
require.NoError(t, store.db.QueryRow(ctx, `SELECT count(*) FROM ladder_tranches`).Scan(&count))
require.Equal(t, len(rows), count, "refused batch for %v must roll back", amount)
}
}
64 changes: 48 additions & 16 deletions internal/server/handler_ladder.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,19 +680,45 @@ func sumTrancheRowAmounts(rows []config.LadderTrancheDB) float64 {
return sum
}

// storedTrancheAmount is the amount a tranche row persists: the planned exact
// amount floored to the column's six decimals (never rounded up; see
// config.LadderAmountFloor). It fails loud on a malformed or non-positive
// planned amount: BuildTranches never emits one, so it means a broken tranche,
// and writing it would be a $0 (or negative) row. stored=false means the
// positive amount is below 0.000001 USD/hr and floors to zero; the caller
// drops that tranche instead of persisting a $0 row. Such an amount is far
// below the provider minimum (0.001 USD/hr), so it could never be purchased.
func storedTrancheAmount(tr *pkgladder.Tranche) (amount *big.Rat, stored bool, err error) {
r, ok := new(big.Rat).SetString(tr.AmountUSDPerHour)
if !ok || r == nil {
return nil, false, fmt.Errorf("tranche %s: malformed amount_usd_per_hour %q (not a parseable rational)", tr.ID, tr.AmountUSDPerHour)
}
if r.Sign() <= 0 {
return nil, false, fmt.Errorf("tranche %s: non-positive amount_usd_per_hour %q", tr.ID, tr.AmountUSDPerHour)
}
floored := config.LadderAmountFloor(r)
return floored, floored.Sign() > 0, nil
}

// 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.
// start (FireAfter <= now: the AfterDays==0 ramp step). It sums the stored
// (floored) amounts, so the display plan never shows more than the persisted
// rows, and skips the tranches buildTrancheDBRows drops; a layer whose
// immediate tranches all floor to zero has no entry. It fails loud on a
// malformed or non-positive 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)
r, stored, err := storedTrancheAmount(tr)
if err != nil {
return nil, err
}
if !stored {
continue
}
if sums[tr.Layer] == nil {
sums[tr.Layer] = new(big.Rat)
Expand All @@ -703,21 +729,27 @@ func immediateTrancheAmounts(tranches []pkgladder.Tranche, now time.Time) (map[p
}

// buildTrancheDBRows converts pkg/ladder Tranche rows to config.LadderTrancheDB
// rows ready for SaveLadderTranches. It fails loud on a malformed
// AmountUSDPerHour (money path): rather than persist a silently 0-coerced
// $0 tranche, it returns an error so the whole run is counted Errored and
// nothing partial is written.
// rows ready for SaveLadderTranches. It fails loud on a malformed or
// non-positive AmountUSDPerHour (money path): rather than persist a silently
// 0-coerced $0 tranche, it returns an error so the whole run is counted
// Errored and nothing partial is written. A positive amount below 0.000001
// USD/hr, which the six-decimal column would store as 0, is dropped with a log
// line instead (see storedTrancheAmount): it is a planner remainder no provider
// can buy, and refusing it would fail the same run again on every rerun.
func buildTrancheDBRows(tranches []pkgladder.Tranche, runID string, configID *string) ([]config.LadderTrancheDB, error) {
rows := make([]config.LadderTrancheDB, 0, len(tranches))
for i := range tranches {
tr := &tranches[i] // use pointer to avoid copying 152-byte Tranche struct per iteration
// AmountUSDHr: parse the RatString back to float64. A missing or
// malformed RatString means BuildTranches produced a broken tranche;
// that should never happen after Validate() passes, so surface it as
// an error instead of writing a $0 row.
r, ok := new(big.Rat).SetString(tr.AmountUSDPerHour)
if !ok || r == nil {
return nil, fmt.Errorf("tranche %s: malformed amount_usd_per_hour %q (not a parseable rational)", tr.ID, tr.AmountUSDPerHour)
// AmountUSDHr: parse the RatString and floor it to the stored scale
// (never round a planned commitment up; see config.LadderAmountFloor).
r, stored, err := storedTrancheAmount(tr)
if err != nil {
return nil, err
}
if !stored {
log.Printf("ladder_run: run %s: dropping tranche %s (layer %s): amount_usd_per_hour %s floors to 0 at the stored six-decimal scale",
runID, tr.ID, tr.Layer, tr.AmountUSDPerHour)
continue
}
amountUSDHr, _ := r.Float64()
runIDCopy := runID
Expand Down
10 changes: 7 additions & 3 deletions internal/server/handler_ladder_immediate_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,17 +225,21 @@ func TestLadderB1_NextRunNetsImmediateRowWithinGrace(t *testing.T) {
}

// 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.
// leaves more live commitment than target-E, and ends at the target. Each ramp
// step is floored to the six-decimal column (#1182), never rounded up, so the
// fixed point can sit at most one micro-dollar per ramp step below the target
// (far under the provider's five-decimal precision) and never above it.
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)
require.LessOrEqual(t, live, ladderB1Target, "day %d over-plans: live=%v", day, live)
}
assert.InDelta(t, ladderB1Target, e.live(ctx, t), 1e-6)
floorSlack := float64(len(ladderB1TwoStep))*1e-6 + 1e-9 // + float8 noise on the summed NUMERIC
assert.GreaterOrEqual(t, e.live(ctx, t), ladderB1Target-floorSlack)
}

// completeBeforeStaleE completes a tranche inside GetLayerStates and then
Expand Down
Loading
Loading