Skip to content

Commit ff55a60

Browse files
committed
fix(db): make cancelled->canceled rename truly dual-compatible during EXPAND
Address CodeRabbit review on PR #1277. The expand-contract rename must accept both spellings/columns throughout the EXPAND phase; only the deferred contract migration (#1278) removes the legacy spelling. Down migration (CRITICAL rollback bug + actor loss): - Reorder the DOWN path so data is converted 'canceled'->'cancelled' BEFORE the 'cancelled'-only CHECK constraints are re-added. The old order re-added the narrowed constraint first, so any row still in the new state violated it and the whole rollback failed. - Backfill canceled_by into cancelled_by BEFORE dropping canceled_by so actor attribution recorded by new code during the deploy window survives. Reads stay dual-compatible: - handler_history.go: accept both 'canceled' and 'cancelled' in the history fetch filter and in the KPI-summary / annotation switches so legacy rows written mid-deploy are neither dropped nor allowed to inflate KPIs. - store_postgres.go: project COALESCE(canceled_by, cancelled_by) on every read so a late legacy write landing in cancelled_by is still surfaced. - handler_purchases.go: the cancel-recovery idempotency check accepts both spellings. Scheduled-cancel routing (doc + predicate reconciliation): - interfaces.go: align the CancelExecutionAtomic doc to the implementation (pending/notified only; scheduled is handled by CancelScheduledExecutionAtomic). - types.go: add IsImmediatelyCancelable (pending/notified) distinct from the broader IsCancelable (adds scheduled). The /cancel paths (cancelPurchaseViaSession, loadCancelableExecution) now gate on the narrow predicate so a scheduled row is routed to a clear "use the revoke endpoint" 409 instead of misrouting into the pending/notified-only CAS and surfacing a misleading "concurrent operation" error. Tests: - New integration test 000078_..._test.go exercises up (both spellings accepted, legacy backfilled) and down (rollback succeeds with 'canceled' rows present, data converted back, actor attribution preserved, narrowed constraint restored). - Unit tests for the cancelable predicates and the scheduled-routing behaviour on both cancel paths.
1 parent bd64087 commit ff55a60

11 files changed

Lines changed: 413 additions & 47 deletions

‎internal/api/handler_history.go‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR
114114
// wave-2) appear in the History view with a Revoke button before the cloud SDK
115115
// call fires. Without this entry the row is invisible to the History UI, making
116116
// the Revoke button unreachable (issue #290, second-wave CR Finding E).
117-
var historyExecutionStatuses = []string{"pending", "notified", "scheduled", "approved", "running", "paused", "completed", "partially_completed", "failed", "expired", "canceled"}
117+
// Both "canceled" (US, current) and "cancelled" (British, legacy) are listed:
118+
// during the expand-contract rename (migration 000078) old code may still write
119+
// "cancelled" before the rolling deploy completes. The contract migration
120+
// (#1278) removes the legacy spelling once the deploy is verified stable; drop
121+
// "cancelled" here at the same time.
122+
var historyExecutionStatuses = []string{"pending", "notified", "scheduled", "approved", "running", "paused", "completed", "partially_completed", "failed", "expired", "canceled", "cancelled"}
118123

119124
// approvalExpiryWindow is how long a pending approval stays actionable
120125
// before the History view flips it to "expired". Aligns with the
@@ -345,7 +350,10 @@ func annotateHistoryRowByStatus(row *config.PurchaseHistoryRecord, exec config.P
345350
row.StatusDescription = exec.Error
346351
case "expired":
347352
row.StatusDescription = "approval link expired (not approved within 7 days)"
348-
case "canceled":
353+
case "canceled", "cancelled":
354+
// "cancelled" (legacy British spelling) is still matched during the
355+
// expand-contract rename (migration 000078) until the contract
356+
// migration (#1278) backfills and drops it.
349357
annotateCancelled(row, exec, approver)
350358
default:
351359
// In-flight (approved/running/scheduled/paused) and audit-gap
@@ -1022,10 +1030,14 @@ func summarizePurchaseHistory(purchases []config.PurchaseHistoryRecord) HistoryS
10221030
case "expired":
10231031
summary.TotalExpired++
10241032
continue
1025-
case "canceled":
1033+
case "canceled", "cancelled":
10261034
// A canceled purchase represents zero committed spend and zero
10271035
// realized savings (issue #736). Exclude from all dollar KPIs and
10281036
// from TotalCompleted -- the money was never committed.
1037+
// "cancelled" (legacy British spelling) is matched alongside
1038+
// "canceled" during the expand-contract rename (migration 000078)
1039+
// so legacy rows can't inflate KPIs mid-deploy; the contract
1040+
// migration (#1278) drops it once the deploy is stable.
10291041
continue
10301042
}
10311043
summary.TotalCompleted++

‎internal/api/handler_purchases.go‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,11 @@ func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID stri
387387
if existing == nil {
388388
return nil, NewClientError(404, fmt.Sprintf("execution %s not found", executionID))
389389
}
390-
if existing.Status != "canceled" {
390+
// Accept both spellings: during the expand-contract rename (migration
391+
// 000078) a concurrent legacy cancel may have written "cancelled" before
392+
// the rolling deploy completes. The contract migration (#1278) backfills
393+
// and drops the legacy spelling, after which "cancelled" can be removed.
394+
if existing.Status != "canceled" && existing.Status != "cancelled" {
391395
return nil, NewClientError(409, fmt.Sprintf(
392396
"execution %s cannot be canceled (status=%s)",
393397
executionID, existing.Status))
@@ -897,6 +901,24 @@ func (h *Handler) cancelPurchase(ctx context.Context, req *events.LambdaFunction
897901
return h.cancelPurchaseViaSession(ctx, req, execution)
898902
}
899903

904+
// guardImmediatelyCancelable returns a 409 ClientError unless the execution is
905+
// in a state the plain cancel CAS (CancelExecutionAtomic, pending/notified)
906+
// handles, and nil when it is cancelable. A "scheduled" row is cancelable but
907+
// only via the /revoke flow (CancelScheduledExecutionAtomic); routing it
908+
// through the cancel path would pass IsCancelable, fail the pending/notified-only
909+
// CAS, and surface a misleading "concurrent operation" 409 -- so it gets a
910+
// clear message directing it to the revoke endpoint instead (issue #290).
911+
// Shared by the session and email-token cancel gates so the policy can't drift.
912+
func guardImmediatelyCancelable(execution *config.PurchaseExecution) error {
913+
if execution.Status == "scheduled" {
914+
return NewClientError(409, fmt.Sprintf("execution %s is scheduled; use the revoke endpoint to cancel it before it fires", execution.ExecutionID))
915+
}
916+
if !execution.IsImmediatelyCancelable() {
917+
return NewClientError(409, fmt.Sprintf("execution %s cannot be canceled (status=%s)", execution.ExecutionID, execution.Status))
918+
}
919+
return nil
920+
}
921+
900922
// cancelPurchaseViaSession is the session-authed branch of cancelPurchase.
901923
// Enforces the cancel-any/cancel-own RBAC matrix, validates the execution
902924
// is in a cancelable state (pending|notified), atomically flips the row
@@ -924,8 +946,8 @@ func (h *Handler) cancelPurchaseViaSession(ctx context.Context, req *events.Lamb
924946
return nil, err
925947
}
926948

927-
if !execution.IsCancelable() {
928-
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be canceled (status=%s)", execution.ExecutionID, execution.Status))
949+
if err := guardImmediatelyCancelable(execution); err != nil {
950+
return nil, err
929951
}
930952

931953
if err := h.authorizeSessionCancel(ctx, session, execution); err != nil {

‎internal/api/handler_purchases_test.go‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2420,6 +2420,39 @@ func TestHandler_cancelPurchase_Session_RejectsEachNonCancelableStatus(t *testin
24202420
}
24212421
}
24222422

2423+
// TestHandler_cancelPurchase_Session_ScheduledRoutedToRevoke is the regression
2424+
// guard for CodeRabbit finding #6 on PR #1277. A "scheduled" row is cancelable
2425+
// (IsCancelable returns true) but ONLY via the /revoke flow
2426+
// (CancelScheduledExecutionAtomic). The /cancel path must NOT pass it to
2427+
// CancelExecutionAtomic (pending/notified-only CAS) -- doing so would fail the
2428+
// CAS and surface a misleading "concurrent operation already transitioned it"
2429+
// 409. Instead the handler returns a clear 409 directing the caller to the
2430+
// revoke endpoint, and never enters the cancel tx.
2431+
func TestHandler_cancelPurchase_Session_ScheduledRoutedToRevoke(t *testing.T) {
2432+
creator := cancelCallerID
2433+
exec := &config.PurchaseExecution{
2434+
ExecutionID: cancelExecID,
2435+
Status: "scheduled",
2436+
CreatedByUserID: &creator,
2437+
}
2438+
session := &Session{UserID: cancelCallerID, Email: "admin@example.com"}
2439+
2440+
// Caller owns the row; cancel-own would authorise it if status allowed.
2441+
handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, true)
2442+
2443+
_, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "")
2444+
require.Error(t, err)
2445+
ce, ok := IsClientError(err)
2446+
require.True(t, ok, "scheduled-on-cancel must be a ClientError")
2447+
assert.Equal(t, 409, ce.code)
2448+
assert.Contains(t, err.Error(), "scheduled")
2449+
assert.Contains(t, err.Error(), "revoke", "error must direct the caller to the revoke endpoint")
2450+
// Must NOT misroute through the pending/notified-only CAS.
2451+
mockConfig.AssertNotCalled(t, "CancelExecutionAtomic", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
2452+
mockConfig.AssertNotCalled(t, "WithTx")
2453+
mockAuth.AssertExpectations(t)
2454+
}
2455+
24232456
// TestHandler_cancelPurchase_Session_AllowsEachCancelableStatus confirms the
24242457
// inverse: pending and notified rows remain cancelable on the session path,
24252458
// guarding against an over-restriction that would break the dashboard cancel

‎internal/config/interfaces.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,12 @@ type StoreInterface interface {
8282
// When non-nil the actor is stamped onto transitioned_by + transitioned_at; when nil,
8383
// transitioned_by is set to NULL and transitioned_at is still set to NOW() for ordering.
8484
TransitionExecutionStatus(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string) (*PurchaseExecution, error)
85-
// CancelExecutionAtomic atomically flips status from pending / notified /
86-
// scheduled to canceled, setting canceled_by. The 'scheduled' status
87-
// supports the Gmail-style pre-fire delay revoke path (issue #290).
85+
// CancelExecutionAtomic atomically flips status from pending / notified to
86+
// canceled, setting canceled_by. The 'scheduled' status is intentionally
87+
// NOT accepted here -- the Gmail-style pre-fire delay revoke path (issue
88+
// #290) has its own CancelScheduledExecutionAtomic so the two flows surface
89+
// distinct CAS race outcomes (this path returns 409 on already-approved;
90+
// scheduled-revoke returns 410 "window closed" on already-fired).
8891
// Returns (true, "canceled", nil) on success and (false, currentStatus,
8992
// nil) when zero rows were affected (the execution had already been
9093
// approved or otherwise transitioned). Must be called inside a WithTx

‎internal/config/store_postgres.go‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,7 @@ func (s *PostgresStore) TransitionExecutionStatus(ctx context.Context, execution
906906
RETURNING plan_id, execution_id, status, step_number, scheduled_date,
907907
notification_sent, approval_token, recommendations,
908908
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
909-
cloud_account_id, source, approved_by, canceled_by, capacity_percent,
909+
cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS canceled_by, capacity_percent,
910910
created_by_user_id, retry_execution_id, retry_attempt_n,
911911
approval_token_expires_at,
912912
executed_by_user_id, executed_at, pre_approval_skip_reason,
@@ -1077,7 +1077,7 @@ func (s *PostgresStore) GetExecutionsByStatuses(ctx context.Context, statuses []
10771077
SELECT plan_id, execution_id, status, step_number, scheduled_date,
10781078
notification_sent, approval_token, recommendations,
10791079
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
1080-
cloud_account_id, source, approved_by, canceled_by, capacity_percent,
1080+
cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS canceled_by, capacity_percent,
10811081
created_by_user_id, retry_execution_id, retry_attempt_n,
10821082
approval_token_expires_at,
10831083
executed_by_user_id, executed_at, pre_approval_skip_reason,
@@ -1118,7 +1118,7 @@ func (s *PostgresStore) GetPlannedExecutions(ctx context.Context, statuses []str
11181118
SELECT plan_id, execution_id, status, step_number, scheduled_date,
11191119
notification_sent, approval_token, recommendations,
11201120
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
1121-
cloud_account_id, source, approved_by, canceled_by, capacity_percent,
1121+
cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS canceled_by, capacity_percent,
11221122
created_by_user_id, retry_execution_id, retry_attempt_n,
11231123
approval_token_expires_at,
11241124
executed_by_user_id, executed_at, pre_approval_skip_reason,
@@ -1143,7 +1143,7 @@ func (s *PostgresStore) GetStaleApprovedExecutions(ctx context.Context, olderTha
11431143
SELECT plan_id, execution_id, status, step_number, scheduled_date,
11441144
notification_sent, approval_token, recommendations,
11451145
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
1146-
cloud_account_id, source, approved_by, canceled_by, capacity_percent,
1146+
cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS canceled_by, capacity_percent,
11471147
created_by_user_id, retry_execution_id, retry_attempt_n,
11481148
approval_token_expires_at,
11491149
executed_by_user_id, executed_at, pre_approval_skip_reason,
@@ -1185,7 +1185,7 @@ func (s *PostgresStore) ListStuckExecutions(ctx context.Context, statuses []stri
11851185
SELECT plan_id, execution_id, status, step_number, scheduled_date,
11861186
notification_sent, approval_token, recommendations,
11871187
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
1188-
cloud_account_id, source, approved_by, canceled_by, capacity_percent,
1188+
cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS canceled_by, capacity_percent,
11891189
created_by_user_id, retry_execution_id, retry_attempt_n,
11901190
approval_token_expires_at,
11911191
executed_by_user_id, executed_at, pre_approval_skip_reason,
@@ -1206,7 +1206,7 @@ func (s *PostgresStore) GetPendingExecutions(ctx context.Context) ([]PurchaseExe
12061206
SELECT plan_id, execution_id, status, step_number, scheduled_date,
12071207
notification_sent, approval_token, recommendations,
12081208
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
1209-
cloud_account_id, source, approved_by, canceled_by, capacity_percent,
1209+
cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS canceled_by, capacity_percent,
12101210
created_by_user_id, retry_execution_id, retry_attempt_n,
12111211
approval_token_expires_at,
12121212
executed_by_user_id, executed_at, pre_approval_skip_reason,
@@ -1230,7 +1230,7 @@ func (s *PostgresStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) (
12301230
SELECT plan_id, execution_id, status, step_number, scheduled_date,
12311231
notification_sent, approval_token, recommendations,
12321232
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
1233-
cloud_account_id, source, approved_by, canceled_by, capacity_percent,
1233+
cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS canceled_by, capacity_percent,
12341234
created_by_user_id, retry_execution_id, retry_attempt_n,
12351235
approval_token_expires_at,
12361236
executed_by_user_id, executed_at, pre_approval_skip_reason,
@@ -1260,7 +1260,7 @@ func (s *PostgresStore) GetExecutionByID(ctx context.Context, executionID string
12601260
SELECT plan_id, execution_id, status, step_number, scheduled_date,
12611261
notification_sent, approval_token, recommendations,
12621262
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
1263-
cloud_account_id, source, approved_by, canceled_by, capacity_percent,
1263+
cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS canceled_by, capacity_percent,
12641264
created_by_user_id, retry_execution_id, retry_attempt_n,
12651265
approval_token_expires_at,
12661266
executed_by_user_id, executed_at, pre_approval_skip_reason,
@@ -1287,7 +1287,7 @@ func (s *PostgresStore) GetExecutionByPlanAndDate(ctx context.Context, planID st
12871287
SELECT plan_id, execution_id, status, step_number, scheduled_date,
12881288
notification_sent, approval_token, recommendations,
12891289
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
1290-
cloud_account_id, source, approved_by, canceled_by, capacity_percent,
1290+
cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS canceled_by, capacity_percent,
12911291
created_by_user_id, retry_execution_id, retry_attempt_n,
12921292
approval_token_expires_at,
12931293
executed_by_user_id, executed_at, pre_approval_skip_reason,
@@ -1471,7 +1471,7 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha
14711471
SELECT plan_id, execution_id, status, step_number, scheduled_date,
14721472
notification_sent, approval_token, recommendations,
14731473
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
1474-
cloud_account_id, source, approved_by, canceled_by, capacity_percent,
1474+
cloud_account_id, source, approved_by, COALESCE(canceled_by, cancelled_by) AS canceled_by, capacity_percent,
14751475
created_by_user_id, retry_execution_id, retry_attempt_n,
14761476
approval_token_expires_at,
14771477
executed_by_user_id, executed_at, pre_approval_skip_reason,

‎internal/config/types.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,20 @@ func (e *PurchaseExecution) IsCancelable() bool {
362362
return e.Status == "pending" || e.Status == "notified" || e.Status == "scheduled"
363363
}
364364

365+
// IsImmediatelyCancelable reports whether an execution can be canceled by the
366+
// plain CancelExecutionAtomic CAS (WHERE status IN ('pending','notified')),
367+
// i.e. via the /cancel endpoints. The "scheduled" state is deliberately
368+
// excluded: it is cancelable (IsCancelable returns true) but only through the
369+
// Gmail-style pre-fire revoke flow (CancelScheduledExecutionAtomic, the
370+
// /revoke endpoint), which surfaces a distinct 410 "window closed" CAS race
371+
// outcome. Gating the /cancel paths on this predicate -- rather than the
372+
// broader IsCancelable -- prevents a "scheduled" row from passing the gate,
373+
// failing the pending/notified-only CAS, and surfacing the misleading
374+
// "concurrent operation already transitioned it" error (issue #290).
375+
func (e *PurchaseExecution) IsImmediatelyCancelable() bool {
376+
return e.Status == "pending" || e.Status == "notified"
377+
}
378+
365379
// RecommendationRecord stores a recommendation with purchase status
366380
type RecommendationRecord struct {
367381
ID string `json:"id" dynamodbav:"id"`

‎internal/config/types_test.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,38 @@ func TestGlobalConfig_GracePeriodFor(t *testing.T) {
401401
assert.Equal(t, DefaultGracePeriodDays, cfg.GracePeriodFor("gcp"))
402402
})
403403
}
404+
405+
// TestPurchaseExecution_CancelablePredicates locks the distinction between the
406+
// two cancelability predicates (CodeRabbit finding #6, PR #1277):
407+
// - IsCancelable is the broad policy predicate: pending/notified/scheduled.
408+
// - IsImmediatelyCancelable is the narrow CAS predicate the /cancel paths
409+
// gate on: pending/notified only. "scheduled" is cancelable but only via
410+
// the Gmail-style revoke flow (CancelScheduledExecutionAtomic), so it must
411+
// NOT be immediately cancelable -- otherwise it misroutes into the
412+
// pending/notified-only CAS and surfaces a misleading "concurrent
413+
// operation" error.
414+
func TestPurchaseExecution_CancelablePredicates(t *testing.T) {
415+
cases := []struct {
416+
status string
417+
cancelable bool
418+
immediatelyCancelable bool
419+
}{
420+
{"pending", true, true},
421+
{"notified", true, true},
422+
{"scheduled", true, false},
423+
{"approved", false, false},
424+
{"running", false, false},
425+
{"paused", false, false},
426+
{"completed", false, false},
427+
{"canceled", false, false},
428+
{"failed", false, false},
429+
{"expired", false, false},
430+
}
431+
for _, tc := range cases {
432+
t.Run(tc.status, func(t *testing.T) {
433+
e := &PurchaseExecution{Status: tc.status}
434+
assert.Equal(t, tc.cancelable, e.IsCancelable(), "IsCancelable(%q)", tc.status)
435+
assert.Equal(t, tc.immediatelyCancelable, e.IsImmediatelyCancelable(), "IsImmediatelyCancelable(%q)", tc.status)
436+
})
437+
}
438+
}

0 commit comments

Comments
 (0)