diff --git a/go.mod b/go.mod index ee1314449..e1f47ec00 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 17d1e6340..15bf7eba5 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/store_postgres_attempts.go b/internal/config/store_postgres_attempts.go index dee617411..aa5a53efe 100644 --- a/internal/config/store_postgres_attempts.go +++ b/internal/config/store_postgres_attempts.go @@ -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; @@ -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 { @@ -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 +} diff --git a/internal/config/store_postgres_attempts_integration_test.go b/internal/config/store_postgres_attempts_integration_test.go index 84c559d43..b2034fbd0 100644 --- a/internal/config/store_postgres_attempts_integration_test.go +++ b/internal/config/store_postgres_attempts_integration_test.go @@ -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" @@ -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-" and "rn-2-". + 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 @@ -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}}, } @@ -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)) @@ -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", ""))) +} diff --git a/internal/database/postgres/migrations/000162_purchase_attempts_unique_purchased.down.sql b/internal/database/postgres/migrations/000162_purchase_attempts_unique_purchased.down.sql new file mode 100644 index 000000000..71968ff6e --- /dev/null +++ b/internal/database/postgres/migrations/000162_purchase_attempts_unique_purchased.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS uq_purchase_attempts_purchased_reservation; diff --git a/internal/database/postgres/migrations/000162_purchase_attempts_unique_purchased.up.sql b/internal/database/postgres/migrations/000162_purchase_attempts_unique_purchased.up.sql new file mode 100644 index 000000000..7687cb86f --- /dev/null +++ b/internal/database/postgres/migrations/000162_purchase_attempts_unique_purchased.up.sql @@ -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'; diff --git a/internal/database/postgres/migrations/000162_purchase_attempts_unique_purchased_test.go b/internal/database/postgres/migrations/000162_purchase_attempts_unique_purchased_test.go new file mode 100644 index 000000000..e4bfc66dd --- /dev/null +++ b/internal/database/postgres/migrations/000162_purchase_attempts_unique_purchased_test.go @@ -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") +} diff --git a/internal/purchase/manager.go b/internal/purchase/manager.go index 41f9a2688..109845911 100644 --- a/internal/purchase/manager.go +++ b/internal/purchase/manager.go @@ -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. @@ -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 diff --git a/internal/purchase/requested_at_test.go b/internal/purchase/requested_at_test.go index 611577428..ebd923e8b 100644 --- a/internal/purchase/requested_at_test.go +++ b/internal/purchase/requested_at_test.go @@ -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{ @@ -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).