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
4 changes: 4 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,10 @@ func (m *mockConfigStore) LockLadderTrancheForRetryTx(ctx context.Context, tx pg
return "", false, nil
}

func (m *mockConfigStore) GetLadderExecutionGate(ctx context.Context, idempotencyKey string) (*config.LadderExecutionGate, error) {
return nil, nil
}

func (m *mockConfigStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (bool, string, error) {
return false, "", nil
}
Expand Down
5 changes: 5 additions & 0 deletions internal/config/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ type StoreInterface interface {
// idempotencyKey (matched as text; found=false for any non-ladder key) so
// Retry can refuse a settled tranche before inserting a successor.
LockLadderTrancheForRetryTx(ctx context.Context, tx pgx.Tx, idempotencyKey string) (status ladder.TrancheStatus, found bool, err error)
// GetLadderExecutionGate returns the ladder config switches and run status
// behind the tranche whose id equals idempotencyKey (matched as text), or
// (nil, nil) for any non-ladder key. The executor rechecks it before a
// provider call and fails closed on error.
GetLadderExecutionGate(ctx context.Context, idempotencyKey string) (*LadderExecutionGate, error)
// RotatePendingApprovalToken sets approval_token_hash and its expiry only
// while the row is still pending/notified, touching no other column, so a
// re-notification can never clobber a concurrent approve/cancel the way a
Expand Down
73 changes: 73 additions & 0 deletions internal/config/store_postgres_ladder_gate_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//go:build integration

package config

// Real PostgreSQL test for GetLadderExecutionGate (platform#118 PR4r). The
// lookup must compare the idempotency key as TEXT: a UUID-typed compare raises
// 22P02 on every non-UUID key and would make the executor refuse every
// non-ladder purchase.

import (
"context"
"testing"
"time"

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

func TestGetLadderExecutionGate(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
store := setupLadderStore(ctx, t)
cfgID := seedLadderConfigWithExtID(ctx, t, store, "731111111111")
run, err := store.SaveLadderRun(ctx, &LadderRunDB{ConfigID: &cfgID, StartedAt: time.Now().UTC(), Status: ladder.RunStatusExecuting})
require.NoError(t, err)
tr := lifecycleTranche(cfgID, 1, ladder.TrancheStatusFired, time.Now().UTC())
tr.RunID = &run.ID
require.NoError(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{tr}))

t.Run("ladder key returns the config switches and run status", func(t *testing.T) {
g, err := store.GetLadderExecutionGate(ctx, tr.ID)
require.NoError(t, err)
require.NotNil(t, g)
require.NotNil(t, g.ConfigEnabled)
require.NotNil(t, g.ConfigMode)
require.NotNil(t, g.RunStatus)
assert.True(t, *g.ConfigEnabled)
assert.Equal(t, "email_approval", *g.ConfigMode)
assert.Equal(t, "executing", *g.RunStatus)
})

t.Run("non-ladder keys are not an error", func(t *testing.T) {
for _, key := range []string{"", tr.ID + ":123456789012", "legacy-exec-key", "not-a-uuid", uuid.NewString()} {
g, err := store.GetLadderExecutionGate(ctx, key)
require.NoError(t, err, "key %q must compare as text, not as a UUID", key)
assert.Nil(t, g, "key %q is not a ladder tranche", key)
}
})

t.Run("canceled run is reported", func(t *testing.T) {
_, err := store.TransitionLadderRunStatus(ctx, run.ID, []ladder.RunStatus{ladder.RunStatusExecuting}, ladder.RunStatusCancelled)
require.NoError(t, err)
g, err := store.GetLadderExecutionGate(ctx, tr.ID)
require.NoError(t, err)
require.NotNil(t, g.RunStatus)
assert.Equal(t, string(ladder.RunStatusCancelled), *g.RunStatus)
})

t.Run("deleted config and run come back nil, not false", func(t *testing.T) {
_, err := store.db.Exec(ctx, `DELETE FROM ladder_runs WHERE id = $1`, run.ID)
require.NoError(t, err)
_, err = store.db.Exec(ctx, `DELETE FROM ladder_configs WHERE id = $1`, cfgID)
require.NoError(t, err)
g, err := store.GetLadderExecutionGate(ctx, tr.ID)
require.NoError(t, err)
require.NotNil(t, g, "the tranche still exists")
assert.Nil(t, g.ConfigEnabled)
assert.Nil(t, g.ConfigMode)
assert.Nil(t, g.RunStatus)
})
}
36 changes: 36 additions & 0 deletions internal/config/store_postgres_ladder_reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,42 @@ func (s *PostgresStore) LockLadderTrancheForRetryTx(ctx context.Context, tx pgx.
return ladder.TrancheStatus(raw), true, nil
}

// LadderExecutionGate is what the executor re-reads, right before a provider
// call, about the ladder tranche an execution belongs to. Each field is a
// pointer because the foreign keys are ON DELETE SET NULL: a nil field means
// the row is gone, which the caller must treat as "refuse", not as a default.
type LadderExecutionGate struct {
ConfigEnabled *bool
ConfigMode *string
RunStatus *string
}

// GetLadderExecutionGate finds the ladder tranche whose id equals an
// execution's idempotency key and returns its config switches and run status.
// The key is compared as text, so non-ladder keys (legacy execution ids,
// "<root>:<account>" multi-account keys, fingerprints) match nothing and
// return (nil, nil): "not a ladder execution". Only a real database error is
// an error, and the caller must fail closed on it.
func (s *PostgresStore) GetLadderExecutionGate(ctx context.Context, idempotencyKey string) (*LadderExecutionGate, error) {
if idempotencyKey == "" {
return nil, nil
}
var g LadderExecutionGate
err := s.db.QueryRow(ctx, `
SELECT c.enabled, c.mode, r.status
FROM ladder_tranches t
LEFT JOIN ladder_configs c ON c.id = t.config_id
LEFT JOIN ladder_runs r ON r.id = t.run_id
WHERE t.id::text = $1`, idempotencyKey).Scan(&g.ConfigEnabled, &g.ConfigMode, &g.RunStatus)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read ladder execution gate for key %q: %w", idempotencyKey, err)
}
return &g, nil
}

// LadderTrancheSettled reports whether a tranche status can no longer lead to
// a purchase through its existing execution: anything but scheduled or fired.
// Unknown statuses count as settled, so Retry fails closed.
Expand Down
18 changes: 18 additions & 0 deletions internal/mocks/stores.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,24 @@ func (m *MockConfigStore) LockLadderTrancheForRetryTx(ctx context.Context, tx pg
return status, args.Bool(1), args.Error(2)
}

// GetLadderExecutionGate mocks the operation. Defaults to (nil, nil), "not a
// ladder execution", when no expectation is registered.
func (m *MockConfigStore) GetLadderExecutionGate(ctx context.Context, idempotencyKey string) (*config.LadderExecutionGate, error) {
m.record("GetLadderExecutionGate", ctx, idempotencyKey)
if !isExpected(&m.Mock, "GetLadderExecutionGate") {
return nil, nil
}
args := m.Called(ctx, idempotencyKey)
if args.Get(0) == nil {
return nil, args.Error(1)
}
gate, ok := args.Get(0).(*config.LadderExecutionGate)
if !ok {
panic(fmt.Sprintf("mock: expected *config.LadderExecutionGate, got %T", args.Get(0)))
}
return gate, args.Error(1)
}

// CancelExecutionAtomic mocks the CancelExecutionAtomic operation.
// Defaults to (true, "cancelled", nil) when no expectation is registered
// so tests that only need the happy path don't require explicit mock setup.
Expand Down
77 changes: 77 additions & 0 deletions internal/purchase/ladder_execution_recheck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package purchase

import (
"context"
"errors"
"fmt"

"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
)

// ladderExecutionRefusal is the execute-time recheck for ladder purchases
// (platform#118, PR4r). A ladder execution can sit approved or scheduled for
// hours, so the switches that allowed it to be created are read again here,
// right before any provider call: both global ladder flags, the ladder config
// (enabled, auto_approve) and the run (not canceled).
//
// An execution belongs to a ladder tranche when its idempotency key equals the
// tranche id. The lookup compares as text, so every other key shape
// ("<root>:<account>" multi-account keys, legacy execution ids, fingerprints)
// simply matches nothing and is not a ladder execution. Only a real lookup
// error refuses, because a recheck that cannot read the switches must not
// guess "allowed".
//
// A refusal is wrapped by the caller as a beforeSubmission error: the row ends
// failed with no_submission stamped, which lets the ladder reconciler fail the
// tranche and release its netting (decideFiredTranche reads that proof).
//
// Accepted residual race: the switch can be flipped after this check passes
// and before the provider call. That window is milliseconds and no lock is
// held across a provider call, so it cannot be closed here.
func (m *Manager) ladderExecutionRefusal(ctx context.Context, exec *config.PurchaseExecution) error {
gate, err := m.config.GetLadderExecutionGate(ctx, exec.IdempotencyKey)
if err != nil {
return fmt.Errorf("refusing to execute: ladder tranche lookup failed: %w", err)
}
if gate == nil {
return nil
}
if err := m.ladderFlagsRefusal(ctx); err != nil {
return err
}
return ladderGateRefusal(gate)
}

func (m *Manager) ladderFlagsRefusal(ctx context.Context) error {
global, err := m.config.GetGlobalConfig(ctx)
if err != nil {
return fmt.Errorf("refusing to execute ladder purchase: global config unreadable: %w", err)
}
if global == nil {
return errors.New("refusing to execute ladder purchase: global config unavailable")
}
if !global.LadderingEnabled {
return errors.New("refusing to execute ladder purchase: ladder planning is switched off")
}
if !global.LadderExecutionEnabled {
return errors.New("refusing to execute ladder purchase: ladder execution is switched off")
}
return nil
}

func ladderGateRefusal(gate *config.LadderExecutionGate) error {
switch {
case gate.ConfigEnabled == nil || gate.ConfigMode == nil:
return errors.New("refusing to execute ladder purchase: its ladder config no longer exists")
case !*gate.ConfigEnabled:
return errors.New("refusing to execute ladder purchase: its ladder config is disabled")
case ladder.LadderMode(*gate.ConfigMode) != ladder.ModeAutoApprove:
return fmt.Errorf("refusing to execute ladder purchase: ladder config mode is %q, not auto_approve", *gate.ConfigMode)
case gate.RunStatus == nil:
return errors.New("refusing to execute ladder purchase: its ladder run no longer exists")
case ladder.RunStatus(*gate.RunStatus) == ladder.RunStatusCancelled:
return errors.New("refusing to execute ladder purchase: its ladder run was canceled")
}
return nil
}
Loading
Loading