Skip to content

Commit ca01672

Browse files
cristimclaude
andauthored
feat(ladder): tranche lifecycle store API and grace-bound netting (#824)
Refs #118. Adds the store half of the tranche status lifecycle; no executor, no flag, nothing reachable buys anything. - LadderFireGrace (24h) and ladderGraceCutoff: one expression shared by in-flight netting and the overdue sweep, so a scheduled row is counted or canceled, never neither and never both. - GetInFlightLadderCommitUSDHr now nets fireable scheduled rows (scheduled_date > asOf - grace) plus fired rows; completed, failed, canceled and past-grace rows are excluded. - TransitionLadderTranche: compare-and-set with an explicit edge table; illegal edges fail loud. - ClaimDueLadderTranches: FOR UPDATE SKIP LOCKED claim that creates the execution row in the same transaction via a callback. Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 6c4be95 commit ca01672

6 files changed

Lines changed: 623 additions & 52 deletions

‎internal/config/store_postgres_ladder.go‎

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -318,11 +318,12 @@ func insertLadderTranchesTx(ctx context.Context, tx pgx.Tx, tranches []LadderTra
318318
return nil
319319
}
320320

321-
// cancelOverdueLadderTranchesTx marks the config's scheduled tranches whose
322-
// scheduled_date is at or before asOf as canceled. No executor transitions
323-
// tranches yet (issue #118), so without this the overdue amount grows with
324-
// every run and a future due-row sweep would buy all of it at once (#544).
325-
// Rows already linked to an execution are left alone.
321+
// cancelOverdueLadderTranchesTx marks the config's scheduled tranches that have
322+
// outlived LadderFireGrace as cancelled: scheduled_date <= ladderGraceCutoff(asOf),
323+
// the exact complement of the netting bound in GetInFlightLadderCommitUSDHr.
324+
// No executor transitions tranches yet (issue #118), so without this the
325+
// overdue amount grows with every run and a future due-row sweep would buy all
326+
// of it at once (#544). Rows already linked to an execution are left alone.
326327
// It runs before the new run's tranches are inserted, so those are never
327328
// touched. A nil configID has nothing to scope to and is a no-op.
328329
func cancelOverdueLadderTranchesTx(ctx context.Context, tx pgx.Tx, configID *string, asOf time.Time) error {
@@ -336,7 +337,7 @@ func cancelOverdueLadderTranchesTx(ctx context.Context, tx pgx.Tx, configID *str
336337
AND status = $3
337338
AND execution_id IS NULL
338339
AND scheduled_date <= $4
339-
`, string(ladder.TrancheStatusCancelled), *configID, string(ladder.TrancheStatusScheduled), asOf)
340+
`, string(ladder.TrancheStatusCancelled), *configID, string(ladder.TrancheStatusScheduled), ladderGraceCutoff(asOf))
340341
if err != nil {
341342
return fmt.Errorf("failed to cancel overdue ladder_tranches config_id=%s: %w", *configID, err)
342343
}
@@ -362,8 +363,8 @@ func (s *PostgresStore) SaveLadderTranches(ctx context.Context, tranches []Ladde
362363
// its tranches. This prevents a status=planned run with zero tranches, which
363364
// the cadence self-gate (keyed on any run's started_at) would otherwise use to
364365
// suppress the retry for the full cadence window. Scheduled tranches of the
365-
// same config that are already due at run.StartedAt are canceled in the same
366-
// transaction, so overdue rows are swept at each persisted run. If run.ID is empty a fresh
366+
// same config that are past LadderFireGrace at run.StartedAt are canceled in
367+
// the same transaction, so expired rows are swept at each persisted run. If run.ID is empty a fresh
367368
// UUID is generated. Returns the persisted run row.
368369
func (s *PostgresStore) SaveLadderRunWithTranches(ctx context.Context, run *LadderRunDB, tranches []LadderTrancheDB) (*LadderRunDB, error) {
369370
if run.ID == "" {
@@ -393,32 +394,29 @@ func (s *PostgresStore) SaveLadderRunWithTranches(ctx context.Context, run *Ladd
393394

394395
// GetInFlightLadderCommitUSDHr returns the total hourly USD commitment already
395396
// in flight for the given config: the SUM of amount_usd_hr for ladder_tranches
396-
// rows where config_id=$1, status = 'scheduled' and scheduled_date > asOf.
397+
// rows where config_id=$1 and either
398+
// - status = 'scheduled' and scheduled_date > ladderGraceCutoff(asOf): not yet
399+
// bought and still fireable (future, or due but within LadderFireGrace), or
400+
// - status = 'fired': claimed for purchase but not yet resolved (approval
401+
// pending, in progress, or outcome unknown). Fired nets regardless of age;
402+
// only a transition to completed or failed releases it.
397403
//
398-
// SCHEDULED ONLY: fired/completed tranches are executed purchases already
399-
// counted in the engine's ExistingUSDPerHour (the provider adapters fold
400-
// payment-pending and active commitments into E). Summing them here as well
401-
// would double-subtract them from the gap and cause under-purchasing. Only
402-
// not-yet-fired (scheduled) tranches are genuinely "in flight" and absent
403-
// from E, so they alone must be netted out of the gap.
404+
// Excluded: completed (counted in the engine's ExistingUSDPerHour; summing it
405+
// too would double-subtract), failed and cancelled (never bought; counting
406+
// them would silently under-buy), and scheduled rows past the grace window
407+
// (cancelled by cancelOverdueLadderTranchesTx with the complementary bound).
404408
//
405-
// NOT YET DUE ONLY: nothing transitions a tranche out of 'scheduled' yet (the
406-
// executor does not exist, issue #118), so a tranche whose scheduled_date has
407-
// passed was never bought. Counting it would net every past plan out of the
408-
// gap forever and stop the ladder planning after its first run.
409-
//
410-
// Returns a non-nil *float64 (zero when no scheduled tranches exist); never
411-
// returns nil without a non-nil error, so callers can pass it directly to
409+
// Returns a non-nil *float64 (zero when nothing is in flight); never returns
410+
// nil without a non-nil error, so callers can pass it directly to
412411
// AllocationInput.InFlightUSDPerHour without an extra nil-guard.
413412
func (s *PostgresStore) GetInFlightLadderCommitUSDHr(ctx context.Context, configID string, asOf time.Time) (*float64, error) {
414413
var total float64
415414
err := s.db.QueryRow(ctx, `
416415
SELECT COALESCE(SUM(amount_usd_hr), 0)
417416
FROM ladder_tranches
418417
WHERE config_id = $1
419-
AND status = $2
420-
AND scheduled_date > $3
421-
`, configID, string(ladder.TrancheStatusScheduled), asOf).Scan(&total)
418+
AND (status = $2 OR (status = $3 AND scheduled_date > $4))
419+
`, configID, string(ladder.TrancheStatusFired), string(ladder.TrancheStatusScheduled), ladderGraceCutoff(asOf)).Scan(&total)
422420
if err != nil {
423421
return nil, fmt.Errorf("GetInFlightLadderCommitUSDHr config_id=%s: %w", configID, err)
424422
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package config
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
9+
"github.com/jackc/pgx/v5"
10+
)
11+
12+
// LadderFireGrace is how late a scheduled ladder tranche may still be fired.
13+
// It is the single definition behind both sides of the boundary: in-flight
14+
// netting counts a scheduled tranche while it is still fireable, and the
15+
// overdue sweep cancels it once it is not. Keeping both on ladderGraceCutoff
16+
// guarantees every scheduled row is counted or canceled, never neither
17+
// (neither would let the next run re-plan an amount the executor can still
18+
// buy, and both would be bought) and never both.
19+
const LadderFireGrace = 24 * time.Hour
20+
21+
// ladderGraceCutoff returns the instant at or before which a scheduled
22+
// tranche is expired: scheduled_date <= cutoff is canceled, and
23+
// scheduled_date > cutoff is still netted and claimable.
24+
func ladderGraceCutoff(asOf time.Time) time.Time {
25+
return asOf.Add(-LadderFireGrace)
26+
}
27+
28+
// ladderTrancheEdges lists the legal tranche status transitions. completed,
29+
// failed and canceled are terminal. A fired tranche whose purchase outcome is
30+
// unknown stays fired (and keeps netting) until it is resolved to completed or
31+
// failed.
32+
var ladderTrancheEdges = map[ladder.TrancheStatus][]ladder.TrancheStatus{
33+
ladder.TrancheStatusScheduled: {ladder.TrancheStatusFired, ladder.TrancheStatusCancelled},
34+
ladder.TrancheStatusFired: {ladder.TrancheStatusCompleted, ladder.TrancheStatusFailed},
35+
}
36+
37+
func validateLadderTrancheEdge(from, to ladder.TrancheStatus) error {
38+
for _, allowed := range ladderTrancheEdges[from] {
39+
if allowed == to {
40+
return nil
41+
}
42+
}
43+
return fmt.Errorf("illegal ladder tranche transition %q -> %q", from, to)
44+
}
45+
46+
// TransitionLadderTranche moves one tranche from `from` to `to` with a
47+
// compare-and-set UPDATE. It returns false (and no error) when the row is
48+
// missing or no longer in `from`, so a lost race is distinguishable from a DB
49+
// failure. An illegal edge is an error. scheduled -> fired is not allowed here
50+
// because a claim must create the execution in the same transaction; use
51+
// ClaimDueLadderTranches. A scheduled tranche that already has an execution
52+
// cannot be canceled.
53+
func (s *PostgresStore) TransitionLadderTranche(ctx context.Context, id string, from, to ladder.TrancheStatus) (bool, error) {
54+
if err := validateLadderTrancheEdge(from, to); err != nil {
55+
return false, err
56+
}
57+
if from == ladder.TrancheStatusScheduled && to == ladder.TrancheStatusFired {
58+
return false, fmt.Errorf("ladder tranche %s: scheduled -> fired must go through ClaimDueLadderTranches", id)
59+
}
60+
tag, err := s.db.Exec(ctx, `
61+
UPDATE ladder_tranches
62+
SET status = $3
63+
WHERE id = $1
64+
AND status = $2
65+
AND ($2 <> 'scheduled' OR execution_id IS NULL)
66+
`, id, string(from), string(to))
67+
if err != nil {
68+
return false, fmt.Errorf("failed to transition ladder_tranche id=%s %s -> %s: %w", id, from, to, err)
69+
}
70+
return tag.RowsAffected() == 1, nil
71+
}
72+
73+
// ClaimDueLadderTranches atomically claims up to limit tranches of configID
74+
// that are due (scheduled_date <= asOf) but not expired (scheduled_date >
75+
// asOf - LadderFireGrace). For each claimed row, in the same transaction,
76+
// create runs and must return the id of the purchase_executions row it
77+
// created; the tranche is then set to fired with that execution_id. If create
78+
// returns an error the whole transaction rolls back and nothing is claimed, so
79+
// a claim and its execution row can never disagree after a crash. Rows locked
80+
// by a concurrent claim are skipped (FOR UPDATE SKIP LOCKED), so exactly one
81+
// caller wins each tranche.
82+
func (s *PostgresStore) ClaimDueLadderTranches(
83+
ctx context.Context,
84+
configID string,
85+
asOf time.Time,
86+
limit int,
87+
create func(ctx context.Context, tx pgx.Tx, tranche LadderTrancheDB) (executionID string, err error),
88+
) ([]LadderTrancheDB, error) {
89+
if limit <= 0 {
90+
return nil, fmt.Errorf("ClaimDueLadderTranches limit must be positive, got %d", limit)
91+
}
92+
var claimed []LadderTrancheDB
93+
err := s.WithTx(ctx, func(tx pgx.Tx) error {
94+
due, err := selectDueLadderTranchesTx(ctx, tx, configID, asOf, limit)
95+
if err != nil {
96+
return err
97+
}
98+
for i := range due {
99+
execID, err := create(ctx, tx, due[i])
100+
if err != nil {
101+
return fmt.Errorf("create execution for ladder_tranche id=%s: %w", due[i].ID, err)
102+
}
103+
if execID == "" {
104+
return fmt.Errorf("create execution for ladder_tranche id=%s returned an empty execution id", due[i].ID)
105+
}
106+
if _, err := tx.Exec(ctx, `
107+
UPDATE ladder_tranches SET status = $2, execution_id = $3
108+
WHERE id = $1 AND status = 'scheduled' AND execution_id IS NULL
109+
`, due[i].ID, string(ladder.TrancheStatusFired), execID); err != nil {
110+
return fmt.Errorf("failed to claim ladder_tranche id=%s: %w", due[i].ID, err)
111+
}
112+
due[i].Status = ladder.TrancheStatusFired
113+
due[i].ExecutionID = &execID
114+
}
115+
claimed = due
116+
return nil
117+
})
118+
if err != nil {
119+
return nil, err
120+
}
121+
return claimed, nil
122+
}
123+
124+
func selectDueLadderTranchesTx(ctx context.Context, tx pgx.Tx, configID string, asOf time.Time, limit int) ([]LadderTrancheDB, error) {
125+
rows, err := tx.Query(ctx, `
126+
SELECT id, config_id, run_id, layer_type, amount_usd_hr, term,
127+
payment_option, scheduled_date, status, created_at
128+
FROM ladder_tranches
129+
WHERE config_id = $1
130+
AND status = 'scheduled'
131+
AND execution_id IS NULL
132+
AND scheduled_date <= $2
133+
AND scheduled_date > $3
134+
ORDER BY scheduled_date, id
135+
LIMIT $4
136+
FOR UPDATE SKIP LOCKED
137+
`, configID, asOf, ladderGraceCutoff(asOf), limit)
138+
if err != nil {
139+
return nil, fmt.Errorf("failed to select due ladder_tranches config_id=%s: %w", configID, err)
140+
}
141+
defer rows.Close()
142+
var out []LadderTrancheDB
143+
for rows.Next() {
144+
var tr LadderTrancheDB
145+
if err := rows.Scan(&tr.ID, &tr.ConfigID, &tr.RunID, &tr.LayerType, &tr.AmountUSDHr, &tr.Term,
146+
&tr.PaymentOption, &tr.ScheduledDate, &tr.Status, &tr.CreatedAt); err != nil {
147+
return nil, fmt.Errorf("failed to scan due ladder_tranche: %w", err)
148+
}
149+
out = append(out, tr)
150+
}
151+
if err := rows.Err(); err != nil {
152+
return nil, fmt.Errorf("failed to read due ladder_tranches: %w", err)
153+
}
154+
return out, nil
155+
}

0 commit comments

Comments
 (0)