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
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,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.5.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261107014806-0b677e19ae94
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261107014806-0b677e19ae94
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261107014806-0b677e19ae94
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261107014806-0b677e19ae94
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261107024538-52d66feb616b
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261107024538-52d66feb616b
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261107024538-52d66feb616b
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261107024538-52d66feb616b
github.com/aws/aws-lambda-go v1.55.1
github.com/aws/aws-sdk-go-v2/service/kms v1.62.1
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261107014806-0b677e19ae94 h1:4AYCeaAqeNBKKLs6Q/FeIRWXCWFUe59/RiBrY9O39Xw=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261107014806-0b677e19ae94/go.mod h1:l9V+ecJCfAigYr6XiK9kpIUemF9bnXOKHJN1s4TPsM0=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261107014806-0b677e19ae94 h1:J7e8eVErWWG+yP80X2GAv5/dTTXQemejLXC1qYsvBWk=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261107014806-0b677e19ae94/go.mod h1:uVh1sdSIwKbHCWZ/TA66m2cR9Odb/36sxDHNKLyuMKY=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261107014806-0b677e19ae94 h1:55Yb/A/GF5yDCH+AxUnHbVZFMFNKMVjxipkgxgBLwss=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261107014806-0b677e19ae94/go.mod h1:h6EewdM42n97XHBC3/aMxZFAMUOoEla4Y18TDYtfC6w=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261107014806-0b677e19ae94 h1:7k9LzEBsSzF2HQFlUDDm6AMj/hs4VKLbL6MoC3Ghkq4=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261107014806-0b677e19ae94/go.mod h1:kfbQ5CsDr1958iSuZq6edcuB0es62XRCXGn/hFFYBW0=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261107024538-52d66feb616b h1:XrhgTf3COxxZzrp/c3fg+QvzFL6sQFcJwFjmxSxdZCM=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261107024538-52d66feb616b/go.mod h1:l9V+ecJCfAigYr6XiK9kpIUemF9bnXOKHJN1s4TPsM0=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261107024538-52d66feb616b h1:9Wsi2XWpe7XJwp14FkBri04UpXWchnEpqFXeahIZx+o=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261107024538-52d66feb616b/go.mod h1:q5NyOoREt9rwRFiAKKRzDWCtv2SVWuNl10SQ28fJV/I=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261107024538-52d66feb616b h1:xXqJJxv3oQsKV+7J8zzmXLgv1YTa95Jxf358pyOwnOw=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261107024538-52d66feb616b/go.mod h1:h6EewdM42n97XHBC3/aMxZFAMUOoEla4Y18TDYtfC6w=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261107024538-52d66feb616b h1:sflGRDYy1a/y5zCLbuoJvQTeHMKVFWoBIxrlbovkwEg=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261107024538-52d66feb616b/go.mod h1:kfbQ5CsDr1958iSuZq6edcuB0es62XRCXGn/hFFYBW0=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
Expand Down
39 changes: 39 additions & 0 deletions internal/config/store_postgres_attempts.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@ package config

import (
"context"
"errors"
"fmt"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)

// uniquePurchasedReservationIndex is created by migration 000162: one
// purchased row per reservation_id across every token and execution.
const uniquePurchasedReservationIndex = "uq_purchase_attempts_purchased_reservation"

// PostgresStore is the platform's common.AttemptJournal (cloud-commitments-go#107).
var _ common.AttemptJournal = (*PostgresStore)(nil)

// PostgresStore also reads other lineages' purchase claims (go#700). Deleting
// ClaimedByOthers breaks the build instead of silently disabling the exclusion.
var _ common.ClaimReader = (*PostgresStore)(nil)

// recordAttemptSQL is one autocommit upsert. The WHERE clause is the
// transition table and mirrors validTransition in cloud-commitments-go's
// providers/aws/services/redshift journal test file (a cross-language mirror;
Expand Down Expand Up @@ -50,6 +60,11 @@ func (s *PostgresStore) RecordAttempt(ctx context.Context, rec common.AttemptRec
tag, err := s.db.Exec(ctx, recordAttemptSQL,
rec.IdempotencyToken, rec.ExecutionID, string(rec.State), rec.ReservationID, rec.OfferingID)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == uniquePurchasedReservationIndex {
return fmt.Errorf("record purchase attempt %s: reservation already recorded as purchased by another attempt",
common.MaskToken(rec.IdempotencyToken))
}
return fmt.Errorf("record purchase attempt %s: %w", common.MaskToken(rec.IdempotencyToken), err)
}
if tag.RowsAffected() == 0 {
Expand Down Expand Up @@ -84,3 +99,27 @@ func (s *PostgresStore) PriorAttempts(ctx context.Context, token string) ([]comm
}
return recs, nil
}

// ClaimedByOthers returns the ids that an attempt under a token other than
// token recorded as purchased (go#700). It runs on its own pool connection,
// like RecordAttempt, and must not be called inside a caller's transaction.
func (s *PostgresStore) ClaimedByOthers(ctx context.Context, token string, ids []string) ([]string, error) {
if len(ids) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT DISTINCT reservation_id
FROM purchase_attempts
-- state lets Postgres use the partial unique index; the table CHECK already limits reservation_id to purchased rows.
WHERE state = 'purchased'
AND reservation_id = ANY($2)
AND idempotency_token <> $1`, token, ids)
if err != nil {
return nil, fmt.Errorf("list claimed reservations %s: %w", common.MaskToken(token), err)
}
claimed, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return nil, fmt.Errorf("scan claimed reservations %s: %w", common.MaskToken(token), err)
}
return claimed, nil
}
89 changes: 80 additions & 9 deletions internal/config/store_postgres_attempts_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -27,11 +28,19 @@ func TestRecordAttempt_TransitionTable(t *testing.T) {
ctx := context.Background()
store := setupLadderStore(ctx, t)

sources := map[string][]common.AttemptRecord{
"none": nil,
"sending": {attemptRec("", common.AttemptSending, "", "")},
"outcome_unknown": {attemptRec("", common.AttemptSending, "", ""), attemptRec("", common.AttemptOutcomeUnknown, "", "")},
"purchased": {attemptRec("", common.AttemptPurchased, "rn-1", "")},
// The unique purchased-reservation index spans executions, so every case
// gets its own ids: "rn-1-<exec>" and "rn-2-<exec>".
sources := map[string]func(exec string) []common.AttemptRecord{
"none": func(string) []common.AttemptRecord { return nil },
"sending": func(e string) []common.AttemptRecord {
return []common.AttemptRecord{attemptRec(e, common.AttemptSending, "", "")}
},
"outcome_unknown": func(e string) []common.AttemptRecord {
return []common.AttemptRecord{attemptRec(e, common.AttemptSending, "", ""), attemptRec(e, common.AttemptOutcomeUnknown, "", "")}
},
"purchased": func(e string) []common.AttemptRecord {
return []common.AttemptRecord{attemptRec(e, common.AttemptPurchased, "rn-1-"+e, "")}
},
}
targets := []struct {
name string
Expand All @@ -43,9 +52,9 @@ func TestRecordAttempt_TransitionTable(t *testing.T) {
map[string]bool{"none": true}},
{"outcome_unknown", func(e string) common.AttemptRecord { return attemptRec(e, common.AttemptOutcomeUnknown, "", "") },
map[string]bool{"sending": true}},
{"purchased_same_id", func(e string) common.AttemptRecord { return attemptRec(e, common.AttemptPurchased, "rn-1", "") },
{"purchased_same_id", func(e string) common.AttemptRecord { return attemptRec(e, common.AttemptPurchased, "rn-1-"+e, "") },
map[string]bool{"none": true, "sending": true, "outcome_unknown": true, "purchased": true}},
{"purchased_other_id", func(e string) common.AttemptRecord { return attemptRec(e, common.AttemptPurchased, "rn-2", "") },
{"purchased_other_id", func(e string) common.AttemptRecord { return attemptRec(e, common.AttemptPurchased, "rn-2-"+e, "") },
map[string]bool{"none": true, "sending": true, "outcome_unknown": true}},
}

Expand All @@ -55,8 +64,7 @@ func TestRecordAttempt_TransitionTable(t *testing.T) {
n++
exec := fmt.Sprintf("exec-%d", n)
t.Run(srcName+"_to_"+tgt.name, func(t *testing.T) {
for _, s := range setup {
s.ExecutionID = exec
for _, s := range setup(exec) {
require.NoError(t, store.RecordAttempt(ctx, s))
}
err := store.RecordAttempt(ctx, tgt.rec(exec))
Expand Down Expand Up @@ -144,3 +152,66 @@ func TestRecordAttempt_OfferingIDWriteOnce(t *testing.T) {
}
}
}

func TestClaimedByOthers(t *testing.T) {
ctx := context.Background()
store := setupLadderStore(ctx, t)
const otherToken = "other-token-0123456"

require.NoError(t, store.RecordAttempt(ctx, attemptRec("exec-own", common.AttemptPurchased, "rn-own", "")))
other := common.AttemptRecord{IdempotencyToken: otherToken, ExecutionID: "exec-o1", State: common.AttemptPurchased, ReservationID: "rn-other"}
require.NoError(t, store.RecordAttempt(ctx, other))
sending := common.AttemptRecord{IdempotencyToken: otherToken, ExecutionID: "exec-o2", State: common.AttemptSending}
require.NoError(t, store.RecordAttempt(ctx, sending))

got, err := store.ClaimedByOthers(ctx, attemptToken, []string{"rn-own", "rn-other", "rn-unknown"})
require.NoError(t, err)
assert.Equal(t, []string{"rn-other"}, got, "only another token's purchased id is claimed")

got, err = store.ClaimedByOthers(ctx, otherToken, []string{"rn-own", "rn-other"})
require.NoError(t, err)
assert.Equal(t, []string{"rn-own"}, got, "the view is symmetric per token")
}

func TestClaimedByOthers_EmptyIDsNoQuery(t *testing.T) {
ctx := context.Background()
store := setupLadderStore(ctx, t)
counter := &queryCounter{dbConn: store.db}
store.db = counter

got, err := store.ClaimedByOthers(ctx, attemptToken, nil)
require.NoError(t, err)
assert.Nil(t, got)
got, err = store.ClaimedByOthers(ctx, attemptToken, []string{})
require.NoError(t, err)
assert.Nil(t, got)
assert.Zero(t, counter.queries, "no ids means no query")
}

type queryCounter struct {
dbConn
queries int
}

func (c *queryCounter) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
c.queries++
return c.dbConn.Query(ctx, sql, args...)
}

// The unique purchased-reservation index (migration 000162) makes the second
// purchased write of one reservation fail, whatever the token.
func TestRecordAttempt_PurchasedReservationUniqueAcrossTokens(t *testing.T) {
ctx := context.Background()
store := setupLadderStore(ctx, t)

require.NoError(t, store.RecordAttempt(ctx, attemptRec("exec-1", common.AttemptPurchased, "rn-1", "")))
second := common.AttemptRecord{IdempotencyToken: "other-token-0123456", ExecutionID: "exec-2", State: common.AttemptPurchased, ReservationID: "rn-1"}
err := store.RecordAttempt(ctx, second)
require.Error(t, err)
assert.Contains(t, err.Error(), "already recorded as purchased")
assert.NotContains(t, err.Error(), "SQLSTATE", "no raw pg detail")
assert.NotContains(t, err.Error(), second.IdempotencyToken, "token stays masked")

// The same row re-recorded with the same id stays allowed.
require.NoError(t, store.RecordAttempt(ctx, attemptRec("exec-1", common.AttemptPurchased, "rn-1", "")))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP INDEX IF EXISTS uq_purchase_attempts_purchased_reservation;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Migration 000162: at most one purchased attempt per reservation (cloud-commitments-go#700).
--
-- A reservation ID recorded as purchased by one attempt can never be recorded
-- as purchased by another, whatever the token or execution. The second write
-- fails, which the provider reports as an unknown outcome instead of a
-- double-counted purchase. The pre-check names offending IDs so an operator can
-- decide which attempt row to keep before re-running the migration.
DO $$
DECLARE
dups TEXT;
BEGIN
SELECT string_agg(reservation_id, ', ' ORDER BY reservation_id) INTO dups
FROM (
SELECT reservation_id
FROM purchase_attempts
WHERE state = 'purchased'
GROUP BY reservation_id
HAVING count(*) > 1
) d;
IF dups IS NOT NULL THEN
RAISE EXCEPTION 'purchase_attempts holds duplicate purchased reservation_id values: %. Resolve the duplicate rows (keep the true buyer, delete or reclassify the rest) and re-run the migration.', dups;
END IF;
END
$$;

CREATE UNIQUE INDEX IF NOT EXISTS uq_purchase_attempts_purchased_reservation
ON purchase_attempts (reservation_id)
WHERE state = 'purchased';
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//go:build integration

package migrations_test

import (
"context"
"testing"

"github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations"
"github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const purchasedIndexName = "uq_purchase_attempts_purchased_reservation"

func purchasedIndexExists(ctx context.Context, t *testing.T, pool *pgxpool.Pool) bool {
t.Helper()
var exists bool
require.NoError(t, pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = $1)`,
purchasedIndexName).Scan(&exists))
return exists
}

// Migration 000162 adds the unique purchased-reservation index; down drops it.
func TestMigration_0162_UniquePurchasedReservation(t *testing.T) {
t.Parallel()
ctx := context.Background()
migrationsPath := getMigrationsPath()

container := testhelpers.RequireEmptyDatabase(ctx, t)
pool := container.DB.Pool()

require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 161))
require.False(t, purchasedIndexExists(ctx, t, pool), "index absent before 000162")

require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 162))
assert.True(t, purchasedIndexExists(ctx, t, pool), "index present after 000162")

_, err := pool.Exec(ctx, `INSERT INTO purchase_attempts (idempotency_token, execution_id, state, reservation_id) VALUES ('t1', 'e1', 'purchased', 'rn-1')`)
require.NoError(t, err)
_, err = pool.Exec(ctx, `INSERT INTO purchase_attempts (idempotency_token, execution_id, state, reservation_id) VALUES ('t2', 'e2', 'purchased', 'rn-1')`)
require.Error(t, err, "a second purchased row for the same reservation is rejected")
_, err = pool.Exec(ctx, `INSERT INTO purchase_attempts (idempotency_token, execution_id, state) VALUES ('t2', 'e2', 'sending')`)
require.NoError(t, err, "non-purchased rows are not constrained")

require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 161))
assert.False(t, purchasedIndexExists(ctx, t, pool), "the down migration drops the index")
_, err = pool.Exec(ctx, `INSERT INTO purchase_attempts (idempotency_token, execution_id, state, reservation_id) VALUES ('t3', 'e3', 'purchased', 'rn-1')`)
require.NoError(t, err, "duplicates are allowed again after down")
}

// The pre-check refuses to build the index over existing duplicates and names them.
func TestMigration_0162_PreCheckNamesDuplicates(t *testing.T) {
t.Parallel()
ctx := context.Background()
migrationsPath := getMigrationsPath()

container := testhelpers.RequireEmptyDatabase(ctx, t)
pool := container.DB.Pool()

require.NoError(t, migrations.MigrateToVersion(ctx, pool, migrationsPath, 161))
for _, row := range [][3]string{{"t1", "e1", "rn-dup"}, {"t2", "e2", "rn-dup"}, {"t3", "e3", "rn-ok"}} {
_, err := pool.Exec(ctx, `INSERT INTO purchase_attempts (idempotency_token, execution_id, state, reservation_id) VALUES ($1, $2, 'purchased', $3)`,
row[0], row[1], row[2])
require.NoError(t, err)
}

err := migrations.MigrateToVersion(ctx, pool, migrationsPath, 162)
require.Error(t, err)
assert.Contains(t, err.Error(), "duplicate purchased reservation_id")
assert.Contains(t, err.Error(), "rn-dup")
assert.NotContains(t, err.Error(), "rn-ok")
assert.False(t, purchasedIndexExists(ctx, t, pool), "no index after a refused migration")
}
12 changes: 10 additions & 2 deletions internal/purchase/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,15 @@ type ManagerConfig struct {
MaxDueRowAge time.Duration
// AttemptJournal records each purchase attempt durably (go#107). Nil keeps
// the provider's own window dedupe.
AttemptJournal common.AttemptJournal
AttemptJournal AttemptJournal
}

// AttemptJournal is the journal the manager hands to providers. It must also
// read claims (go#700), so a wrapper that hides ClaimedByOthers fails to
// compile instead of silently disabling the cross-lineage exclusion.
type AttemptJournal interface {
common.AttemptJournal
common.ClaimReader
}

// Manager handles purchase workflow.
Expand All @@ -64,7 +72,7 @@ type Manager struct {
bastionSTS credentials.STSClientFactory
ambientAWSCreds aws.CredentialsProvider
credStore credentials.CredentialStore
attemptJournal common.AttemptJournal
attemptJournal AttemptJournal
providerFactory provider.FactoryInterface
oidcSigner oidc.Signer
defaults PurchaseDefaults
Expand Down
5 changes: 4 additions & 1 deletion internal/purchase/requested_at_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func runFanOutCapturingOpts(t *testing.T, service common.ServiceType, mk func(st

// runFanOutCapturingOptsWithJournal is runFanOutCapturingOpts on a Manager
// whose attempt journal is journal.
func runFanOutCapturingOptsWithJournal(t *testing.T, service common.ServiceType, mk func(string) config.RecommendationRecord, journal common.AttemptJournal) []common.PurchaseOptions {
func runFanOutCapturingOptsWithJournal(t *testing.T, service common.ServiceType, mk func(string) config.RecommendationRecord, journal AttemptJournal) []common.PurchaseOptions {
t.Helper()
ctx := context.Background()
accounts := []config.CloudAccount{
Expand Down Expand Up @@ -376,6 +376,9 @@ func (*fakeAttemptJournal) PriorAttempts(context.Context, string) ([]common.Atte
return nil, nil
}
func (*fakeAttemptJournal) RecordAttempt(context.Context, common.AttemptRecord) error { return nil }
func (*fakeAttemptJournal) ClaimedByOthers(context.Context, string, []string) ([]string, error) {
return nil, nil
}

// The journal reaches every per-rec dispatch together with the per-account
// ExecutionID the provider keys its attempt rows on (go#107).
Expand Down
Loading