From 2fedce21cc9652c01190bfcb1539ea4768d6de9b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 13 Oct 2026 12:16:37 +0200 Subject: [PATCH] feat(purchase): recheck ladder switches before any provider call (#118 PR4r) Ladder executions can wait approved or scheduled for hours. Re-read the global ladder flags, the ladder config (enabled, auto_approve) and the run (not canceled) in executeAndFinalize, so every executor entry (approve, fire, claimAndExecute/SQS, re-drive) is covered. The tranche is found by comparing the idempotency key to ladder_tranches.id as text; other key shapes are not ladder executions. A lookup error refuses. A refusal is a beforeSubmission error, so the row ends failed with no_submission and the reconciler can release netting. Inert: nothing creates ladder executions yet. Co-Authored-By: Claude Sonnet 5.5 --- internal/analytics/collector_test.go | 4 + internal/config/interfaces.go | 5 + ...e_postgres_ladder_gate_integration_test.go | 73 ++++++++ .../config/store_postgres_ladder_reconcile.go | 36 ++++ internal/mocks/stores.go | 18 ++ internal/purchase/ladder_execution_recheck.go | 77 ++++++++ .../purchase/ladder_execution_recheck_test.go | 171 ++++++++++++++++++ internal/purchase/manager.go | 23 ++- internal/server/test_helpers_test.go | 4 + 9 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 internal/config/store_postgres_ladder_gate_integration_test.go create mode 100644 internal/purchase/ladder_execution_recheck.go create mode 100644 internal/purchase/ladder_execution_recheck_test.go diff --git a/internal/analytics/collector_test.go b/internal/analytics/collector_test.go index 3a61e057..781dee31 100644 --- a/internal/analytics/collector_test.go +++ b/internal/analytics/collector_test.go @@ -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 } diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index 04aa5276..563b3993 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -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 diff --git a/internal/config/store_postgres_ladder_gate_integration_test.go b/internal/config/store_postgres_ladder_gate_integration_test.go new file mode 100644 index 00000000..48592b97 --- /dev/null +++ b/internal/config/store_postgres_ladder_gate_integration_test.go @@ -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) + }) +} diff --git a/internal/config/store_postgres_ladder_reconcile.go b/internal/config/store_postgres_ladder_reconcile.go index 301d5604..23417e25 100644 --- a/internal/config/store_postgres_ladder_reconcile.go +++ b/internal/config/store_postgres_ladder_reconcile.go @@ -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, +// ":" 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. diff --git a/internal/mocks/stores.go b/internal/mocks/stores.go index c9d2b4eb..d7425564 100644 --- a/internal/mocks/stores.go +++ b/internal/mocks/stores.go @@ -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. diff --git a/internal/purchase/ladder_execution_recheck.go b/internal/purchase/ladder_execution_recheck.go new file mode 100644 index 00000000..0a64889f --- /dev/null +++ b/internal/purchase/ladder_execution_recheck.go @@ -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 +// (":" 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 +} diff --git a/internal/purchase/ladder_execution_recheck_test.go b/internal/purchase/ladder_execution_recheck_test.go new file mode 100644 index 00000000..e4d1023c --- /dev/null +++ b/internal/purchase/ladder_execution_recheck_test.go @@ -0,0 +1,171 @@ +package purchase + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// Platform#118 PR4r: the execute-time recheck for ladder purchases. Like the +// armed re-drive tests, every case asserts on the provider call count: the +// only thing that matters is whether money moved. The store is a mock +// (FIXTURE); the real text-compare lookup is covered by the integration test in +// internal/config. + +const ladderTrancheKey = "0f0f0f0f-1111-2222-3333-444444444444" + +func ptr[T any](v T) *T { return &v } + +// openGate is a gate that allows execution; each case flips one field. +func openGate() *config.LadderExecutionGate { + return &config.LadderExecutionGate{ConfigEnabled: ptr(true), ConfigMode: ptr("auto_approve"), RunStatus: ptr("executing")} +} + +func ladderGlobal(planning, execution bool) *config.GlobalConfig { + return &config.GlobalConfig{LadderingEnabled: planning, LadderExecutionEnabled: execution} +} + +// ladderHarness is armedHarness with the global config and the tranche lookup +// replaced by the case under test. +func ladderHarness(t *testing.T, key string, gate *config.LadderExecutionGate, gateErr error, global *config.GlobalConfig, globalErr error) (*Manager, *MockConfigStore, *armedPurchaseRecorder, *config.PurchaseExecution) { + t.Helper() + exec := armedExecution("aws", "savingsplans", 0, "approved") + exec.IdempotencyKey = key + mgr, store, rec := armedHarness(t, common.ServiceSavingsPlansAll) + kept := store.ExpectedCalls[:0] + for _, c := range store.ExpectedCalls { + if c.Method != "GetGlobalConfig" { + kept = append(kept, c) + } + } + store.ExpectedCalls = kept + store.On("GetGlobalConfig", mock.Anything).Return(global, globalErr).Maybe() + store.On("GetLadderExecutionGate", mock.Anything, key).Return(gate, gateErr).Maybe() + store.On("GetExecutionByID", mock.Anything, exec.ExecutionID).Return(exec, nil).Maybe() + return mgr, store, rec, exec +} + +func runViaSQS(t *testing.T, mgr *Manager, store *MockConfigStore, exec *config.PurchaseExecution) { + t.Helper() + expectClaim(store, exec, []string{"approved", "pending", "notified"}) + body, err := json.Marshal(AsyncMessage{Type: MessageTypeExecutePurchase, ExecutionID: exec.ExecutionID}) + require.NoError(t, err) + _ = mgr.ProcessMessage(context.Background(), string(body)) +} + +// TestLadderRecheck_RefusesBeforeProviderCall covers every way the switches can +// be off, and the fail-closed lookups. Each must stop the buy and end the row +// failed with the no_submission proof, so the reconciler can release netting. +func TestLadderRecheck_RefusesBeforeProviderCall(t *testing.T) { + gateWith := func(mut func(*config.LadderExecutionGate)) *config.LadderExecutionGate { + g := openGate() + mut(g) + return g + } + cases := []struct { + name string + gate *config.LadderExecutionGate + gateErr error + global *config.GlobalConfig + globalErr error + want string + }{ + {"execution flag off", openGate(), nil, ladderGlobal(true, false), nil, "execution is switched off"}, + {"planning flag off", openGate(), nil, ladderGlobal(false, true), nil, "planning is switched off"}, + {"config disabled", gateWith(func(g *config.LadderExecutionGate) { g.ConfigEnabled = ptr(false) }), nil, ladderGlobal(true, true), nil, "config is disabled"}, + {"config mode email_approval", gateWith(func(g *config.LadderExecutionGate) { g.ConfigMode = ptr("email_approval") }), nil, ladderGlobal(true, true), nil, "not auto_approve"}, + {"run canceled", gateWith(func(g *config.LadderExecutionGate) { g.RunStatus = ptr(string(ladder.RunStatusCancelled)) }), nil, ladderGlobal(true, true), nil, "run was canceled"}, + {"config deleted", gateWith(func(g *config.LadderExecutionGate) { g.ConfigEnabled, g.ConfigMode = nil, nil }), nil, ladderGlobal(true, true), nil, "config no longer exists"}, + {"run deleted", gateWith(func(g *config.LadderExecutionGate) { g.RunStatus = nil }), nil, ladderGlobal(true, true), nil, "run no longer exists"}, + {"tranche lookup DB error", nil, errors.New("connection reset"), ladderGlobal(true, true), nil, "tranche lookup failed"}, + {"global config read error", openGate(), nil, nil, errors.New("connection reset"), "global config unreadable"}, + {"global config nil", openGate(), nil, nil, nil, "global config unavailable"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mgr, store, rec, exec := ladderHarness(t, ladderTrancheKey, tc.gate, tc.gateErr, tc.global, tc.globalErr) + var saved []config.PurchaseExecution + store.SavePurchaseExecutionFn = func(_ context.Context, e *config.PurchaseExecution) error { + saved = append(saved, *e) + return nil + } + runViaSQS(t, mgr, store, exec) + + assert.Equal(t, 0, rec.count(), "a refused ladder execution must reach no provider") + require.NotEmpty(t, saved) + final := saved[len(saved)-1] + assert.Equal(t, "failed", final.Status) + assert.True(t, final.NoSubmission, "the refusal is a beforeSubmission error, so no_submission must be stamped") + assert.Contains(t, final.Error, tc.want) + }) + } +} + +func TestLadderRecheck_AllowsWhenEverythingIsOn(t *testing.T) { + mgr, store, rec, exec := ladderHarness(t, ladderTrancheKey, openGate(), nil, ladderGlobal(true, true), nil) + runViaSQS(t, mgr, store, exec) + assert.Equal(t, 1, rec.count(), "an armed ladder execution must still buy exactly once") +} + +// Non-ladder keys must be untouched: the lookup returns no tranche for them, +// and the recheck must not read the global flags or refuse. The flags are OFF +// here, so a recheck that ignored "not a ladder execution" would refuse. +func TestLadderRecheck_NonLadderKeysUnaffected(t *testing.T) { + for name, key := range map[string]string{ + "multi-account key": ladderTrancheKey + ":123456789012", + "legacy ExecutionID": "44444444-5555-6666-7777-888888888801", + "empty key": "", + } { + t.Run(name, func(t *testing.T) { + mgr, store, rec, exec := ladderHarness(t, key, nil, nil, ladderGlobal(false, false), nil) + runViaSQS(t, mgr, store, exec) + assert.Equal(t, 1, rec.count(), "a non-ladder purchase must not be gated by the ladder switches") + }) + } +} + +// Every executor entry point reaches executeAndFinalize, so the recheck must +// stop each one. ProcessScheduledPurchases and the SQS message both land in +// claimAndExecute, the first case below. +func TestLadderRecheck_EveryExecutorEntryRefuses(t *testing.T) { + off := ladderGlobal(true, false) + t.Run("approve", func(t *testing.T) { + mgr, store, rec, exec := ladderHarness(t, ladderTrancheKey, openGate(), nil, off, nil) + exec.Status = "pending" + expectClaim(store, exec, []string{"pending", "notified"}) + _, _, err := mgr.ApproveAndExecute(context.Background(), exec.ExecutionID, "operator@example.com", nil) + require.Error(t, err) + assert.Equal(t, 0, rec.count()) + }) + t.Run("claimAndExecute (SQS and cron sweep)", func(t *testing.T) { + mgr, store, rec, exec := ladderHarness(t, ladderTrancheKey, openGate(), nil, off, nil) + runViaSQS(t, mgr, store, exec) + assert.Equal(t, 0, rec.count()) + }) + t.Run("scheduler fire", func(t *testing.T) { + mgr, store, rec, exec := ladderHarness(t, ladderTrancheKey, openGate(), nil, off, nil) + exec.Status = "scheduled" + store.On("GetScheduledExecutionsDue", mock.Anything).Return([]config.PurchaseExecution{*exec}, nil).Once() + expectClaim(store, exec, []string{"scheduled"}) + result, err := mgr.FireScheduledDelayedPurchases(context.Background()) + require.NoError(t, err) + assert.Equal(t, 0, rec.count()) + assert.Equal(t, 0, result.Fired) + }) + t.Run("stranded re-drive", func(t *testing.T) { + mgr, store, rec, exec := ladderHarness(t, ladderTrancheKey, openGate(), nil, off, nil) + store.On("GetStaleApprovedExecutions", mock.Anything, mock.Anything).Return([]config.PurchaseExecution{*exec}, nil).Once() + expectClaim(store, exec, []string{"approved"}) + _, err := mgr.RecoverStrandedApprovals(context.Background()) + require.NoError(t, err) + assert.Equal(t, 0, rec.count()) + }) +} diff --git a/internal/purchase/manager.go b/internal/purchase/manager.go index 286713ad..132afc0b 100644 --- a/internal/purchase/manager.go +++ b/internal/purchase/manager.go @@ -241,12 +241,7 @@ func isMultiAccountAckable(execErr error) bool { func (m *Manager) executeAndFinalize(ctx context.Context, exec *config.PurchaseExecution) error { // Last line of defense before money moves (issue #1718). Every executor // entry point funnels through here, so one check covers all of them. - execErr := armedRedriveRefusal(exec) - if execErr == nil { - if refusal := m.staleAzurePricingRefusal(ctx, exec); refusal != nil { - execErr = beforeSubmission(refusal) - } - } + execErr := m.preExecutionRefusal(ctx, exec) if execErr == nil { execErr = m.executePurchase(ctx, exec) } @@ -278,6 +273,22 @@ func (m *Manager) executeAndFinalize(ctx context.Context, exec *config.PurchaseE return execErr } +// preExecutionRefusal runs every check that must pass before any provider call. +// All refusals except the armed re-drive one are beforeSubmission errors, so +// the row ends failed with no_submission stamped. +func (m *Manager) preExecutionRefusal(ctx context.Context, exec *config.PurchaseExecution) error { + if err := armedRedriveRefusal(exec); err != nil { + return err + } + if refusal := m.staleAzurePricingRefusal(ctx, exec); refusal != nil { + return beforeSubmission(refusal) + } + if refusal := m.ladderExecutionRefusal(ctx, exec); refusal != nil { + return beforeSubmission(refusal) + } + return nil +} + // beforeSubmissionError marks a failure that happened before any cloud // purchase call was made for the execution: the code path itself proves no // commitment can exist, independent of the error text or of provider diff --git a/internal/server/test_helpers_test.go b/internal/server/test_helpers_test.go index cc06c0d6..36e09f5e 100644 --- a/internal/server/test_helpers_test.go +++ b/internal/server/test_helpers_test.go @@ -182,6 +182,10 @@ func (m *mockConfigStoreForHealth) LockLadderTrancheForRetryTx(ctx context.Conte return "", false, nil } +func (m *mockConfigStoreForHealth) GetLadderExecutionGate(ctx context.Context, idempotencyKey string) (*config.LadderExecutionGate, error) { + return nil, nil +} + func (m *mockConfigStoreForHealth) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (bool, string, error) { return false, "", nil }