Skip to content

Commit 34c91bb

Browse files
IAmJSDclaude
andauthored
fix(config): let an account with only terminal executions be deleted (#1020)
* fix(config): let an account with only terminal executions be deleted Migration 000053 made purchase_executions.cloud_account_id ON DELETE RESTRICT, so one completed/failed/canceled/expired execution blocked the account delete forever, and the handler answered with a 409 asking the operator to cancel pending purchases that did not exist. DeleteCloudAccount now detaches the account's terminal executions (cloud_account_id = NULL, status in the new TerminalExecutionStatuses allowlist) in its transaction before the DELETE, keeping them as history. Live rows stay attached, so the FK still refuses the delete. The deleteAccount preflight adds CountLiveExecutionsForAccount and answers a 409 with reason "active_executions" for approved, running, scheduled or paused rows, after the existing pending/notified 409. Migration 000127 gives purchase_executions its own updated_at trigger that keeps updated_at on a detach-only UPDATE, so deleting an account does not pull old failures back into the plan health window or restart the retention clock of canceled rows. Closes #265 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(db): renumber executions-detach migration to 000128 after #905 #905 merged its own 000127 (never_started_legacy_ramps_to_new), so this PR's migration moves to 000128. The down step in its test now targets the real predecessor instead of a hard-coded 126. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): re-run the delete preflight after an FK-violation race When DeleteCloudAccount hits SQLSTATE 23503 because a row raced past the preflight, re-query so the client gets the structured pending (with counts) or active 409 for that row. Fall back to the bare pending 409 if the re-query fails or no longer sees a blocker (CodeRabbit on #1020). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cb875f4 commit 34c91bb

14 files changed

Lines changed: 794 additions & 43 deletions

‎internal/analytics/collector_test.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,10 @@ func (m *mockConfigStore) CountPendingExecutionsForAccount(ctx context.Context,
299299
return 0, nil
300300
}
301301

302+
func (m *mockConfigStore) CountLiveExecutionsForAccount(ctx context.Context, accountID string) (int, error) {
303+
return 0, nil
304+
}
305+
302306
func (m *mockConfigStore) ListPendingExecutionIDsForAccount(ctx context.Context, accountID string) ([]string, error) {
303307
return nil, nil
304308
}

‎internal/api/handler_accounts.go‎

Lines changed: 81 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -588,12 +588,12 @@ func (h *Handler) updateAccount(ctx context.Context, httpReq *events.LambdaFunct
588588

589589
// deleteAccount handles DELETE /api/accounts/:id.
590590
//
591-
// Preflights the delete against pending/notified purchase_executions
592-
// (issue #606). Migration 000053 also enforces ON DELETE RESTRICT at the
593-
// DB level so a race that slips past the preflight still can't orphan a
594-
// pending execution — but the preflight gives the frontend a structured
595-
// 409 (with the count + execution_ids) so it can offer a Cancel-All-Then-
596-
// Delete affordance instead of surfacing a raw FK-violation 500.
591+
// Preflights the delete against the purchase_executions that still need the
592+
// account (preflightAccountDelete). Executions in a terminal status do not
593+
// block: DeleteCloudAccount detaches them (cloud_account_id = NULL) and keeps
594+
// them as history (issue #265). Migration 000053 also enforces ON DELETE
595+
// RESTRICT at the DB level, so a live execution that races past the
596+
// preflight still can't be orphaned.
597597
func (h *Handler) deleteAccount(ctx context.Context, req *events.LambdaFunctionURLRequest, id string) (any, error) {
598598
if err := validateUUID(id); err != nil {
599599
return nil, err
@@ -611,46 +611,26 @@ func (h *Handler) deleteAccount(ctx context.Context, req *events.LambdaFunctionU
611611
return nil, err
612612
}
613613

614-
// Preflight: refuse the delete if pending/notified executions still
615-
// reference this account. The frontend uses the pending_count + the
616-
// pending_execution_ids list to drive the Cancel-All-Then-Delete UX.
617-
pendingCount, err := h.config.CountPendingExecutionsForAccount(ctx, id)
618-
if err != nil {
619-
return nil, fmt.Errorf("accounts: %w", err)
620-
}
621-
if pendingCount > 0 {
622-
execIDs, listErr := h.config.ListPendingExecutionIDsForAccount(ctx, id)
623-
if listErr != nil {
624-
// Count succeeded but list failed — still surface the 409 so the
625-
// operator gets a clear "cancel them first" message instead of
626-
// the raw FK error from the eventual DB delete. The list payload
627-
// is omitted; the frontend falls back to a generic message.
628-
return nil, NewClientErrorWithDetails(409,
629-
fmt.Sprintf("cannot delete account: %d pending purchase(s) must be canceled first", pendingCount),
630-
map[string]any{
631-
"pending_count": pendingCount,
632-
"reason": "pending_executions",
633-
})
634-
}
635-
return nil, NewClientErrorWithDetails(409,
636-
fmt.Sprintf("cannot delete account: %d pending purchase(s) must be canceled first", pendingCount),
637-
map[string]any{
638-
"pending_count": pendingCount,
639-
"pending_execution_ids": execIDs,
640-
"reason": "pending_executions",
641-
})
614+
if err := h.preflightAccountDelete(ctx, id); err != nil {
615+
return nil, err
642616
}
643617

644618
if err := h.config.DeleteCloudAccount(ctx, id); err != nil {
645-
// Race: the preflight count was 0 but a pending execution row was
646-
// inserted concurrently before we issued the DELETE. Migration 000053
647-
// enforces ON DELETE RESTRICT, so Postgres raises SQLSTATE 23503
648-
// (foreign_key_violation). Map this to the same structured 409 the
649-
// preflight branch returns so the frontend's Cancel-All-Then-Delete
650-
// affordance still kicks in — minus the count/ids, which we can't
651-
// supply without re-querying (and the operator can just retry).
619+
// Race: the preflight found no live execution but one was inserted
620+
// concurrently before we issued the DELETE. Migration 000053 enforces
621+
// ON DELETE RESTRICT, so Postgres raises SQLSTATE 23503
622+
// (foreign_key_violation). Terminal executions never cause this:
623+
// DeleteCloudAccount detaches them in the same transaction. Re-run the
624+
// preflight so the client gets the same structured 409 (pending with
625+
// counts and ids, or active), and fall back to a bare pending 409 if
626+
// the re-query fails or no longer sees the blocker.
652627
var pgErr *pgconn.PgError
653628
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
629+
if perr := h.preflightAccountDelete(ctx, id); perr != nil {
630+
if ce, ok := IsClientError(perr); ok {
631+
return nil, ce
632+
}
633+
}
654634
return nil, NewClientErrorWithDetails(409,
655635
"cannot delete account: pending purchase(s) must be canceled first",
656636
map[string]any{
@@ -663,6 +643,66 @@ func (h *Handler) deleteAccount(ctx context.Context, req *events.LambdaFunctionU
663643
return nil, nil
664644
}
665645

646+
// preflightAccountDelete refuses the delete with a structured 409 while any
647+
// purchase execution still needs the account:
648+
//
649+
// - pending/notified rows (reason "pending_executions", issue #606): the
650+
// frontend uses pending_count + pending_execution_ids to drive the
651+
// Cancel-All-Then-Delete UX.
652+
// - any other non-terminal row, e.g. approved, running, scheduled or
653+
// paused (reason "active_executions", issue #265). These can't be
654+
// canceled from the account page, so the operator has to let them
655+
// finish, or revoke or delete them, first.
656+
//
657+
// Terminal rows are not counted: DeleteCloudAccount detaches them and keeps
658+
// them as history.
659+
func (h *Handler) preflightAccountDelete(ctx context.Context, id string) error {
660+
pendingCount, err := h.config.CountPendingExecutionsForAccount(ctx, id)
661+
if err != nil {
662+
return fmt.Errorf("accounts: %w", err)
663+
}
664+
if pendingCount > 0 {
665+
return h.pendingExecutionsConflict(ctx, id, pendingCount)
666+
}
667+
668+
liveCount, err := h.config.CountLiveExecutionsForAccount(ctx, id)
669+
if err != nil {
670+
return fmt.Errorf("accounts: %w", err)
671+
}
672+
if liveCount > 0 {
673+
return NewClientErrorWithDetails(409,
674+
fmt.Sprintf("cannot delete account: %d in-progress purchase(s) (approved, running, scheduled or paused) still use it; "+
675+
"wait for them to finish, or revoke or delete them, first", liveCount),
676+
map[string]any{
677+
"active_count": liveCount,
678+
"reason": "active_executions",
679+
})
680+
}
681+
return nil
682+
}
683+
684+
// pendingExecutionsConflict builds the issue #606 409 for pendingCount
685+
// pending/notified executions, with their IDs when they can be listed.
686+
func (h *Handler) pendingExecutionsConflict(ctx context.Context, id string, pendingCount int) error {
687+
msg := fmt.Sprintf("cannot delete account: %d pending purchase(s) must be canceled first", pendingCount)
688+
execIDs, listErr := h.config.ListPendingExecutionIDsForAccount(ctx, id)
689+
if listErr != nil {
690+
// Count succeeded but list failed — still surface the 409 so the
691+
// operator gets a clear "cancel them first" message instead of
692+
// the raw FK error from the eventual DB delete. The list payload
693+
// is omitted; the frontend falls back to a generic message.
694+
return NewClientErrorWithDetails(409, msg, map[string]any{
695+
"pending_count": pendingCount,
696+
"reason": "pending_executions",
697+
})
698+
}
699+
return NewClientErrorWithDetails(409, msg, map[string]any{
700+
"pending_count": pendingCount,
701+
"pending_execution_ids": execIDs,
702+
"reason": "pending_executions",
703+
})
704+
}
705+
666706
// parseAndValidateCredentialsRequest performs all input validation that must
667707
// run before we touch the database: UUID format, permission, body parse,
668708
// credential_type allowlist, and per-type payload schema. Pulled out of

‎internal/api/handler_accounts_test.go‎

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,131 @@ func TestDeleteAccount_PendingZero_DeletesNormally(t *testing.T) {
530530
assert.True(t, deleteCalled, "delete should proceed when no pending executions")
531531
}
532532

533+
// TestDeleteAccount_ActiveExecutions_Returns409 asserts the issue #265
534+
// preflight: with no cancelable pending/notified rows but an approved,
535+
// running, scheduled or paused row still on the account, the handler refuses
536+
// with a 409 that says why (reason "active_executions" + count) instead of
537+
// letting the FK raise the misleading "cancel pending purchases" 409.
538+
func TestDeleteAccount_ActiveExecutions_Returns409(t *testing.T) {
539+
ctx := context.Background()
540+
mockAuth := new(MockAuthService)
541+
setupAdminAuth(ctx, mockAuth)
542+
543+
store := setupAdminMock(ctx)
544+
store.CountPendingExecutionsForAccountFn = func(_ context.Context, _ string) (int, error) { return 0, nil }
545+
var liveAccount string
546+
store.CountLiveExecutionsForAccountFn = func(_ context.Context, id string) (int, error) {
547+
liveAccount = id
548+
return 2, nil
549+
}
550+
deleteCalled := false
551+
store.DeleteCloudAccountFn = func(_ context.Context, _ string) error {
552+
deleteCalled = true
553+
return nil
554+
}
555+
handler := &Handler{auth: mockAuth, config: store}
556+
557+
const id = "11111111-1111-1111-1111-111111111111"
558+
result, err := handler.deleteAccount(ctx, adminRequest(""), id)
559+
assert.Nil(t, result)
560+
require.Error(t, err)
561+
ce, ok := IsClientError(err)
562+
require.True(t, ok, "expected ClientError, got %v", err)
563+
assert.Equal(t, 409, ce.code)
564+
assert.Contains(t, ce.message, "2 in-progress purchase(s)")
565+
assert.NotContains(t, ce.message, "canceled first", "these rows cannot be canceled; the message must not say to")
566+
details := ce.Details()
567+
require.NotNil(t, details)
568+
assert.EqualValues(t, 2, details["active_count"])
569+
assert.Equal(t, "active_executions", details["reason"])
570+
assert.Equal(t, id, liveAccount, "the live count must be scoped to the account being deleted")
571+
assert.False(t, deleteCalled, "DeleteCloudAccount must NOT be called while live executions exist")
572+
}
573+
574+
// TestDeleteAccount_PendingTakesPrecedenceOverActive asserts the #606
575+
// Cancel-All-Then-Delete 409 still wins when cancelable rows exist, so the
576+
// frontend keeps getting the execution IDs it needs.
577+
func TestDeleteAccount_PendingTakesPrecedenceOverActive(t *testing.T) {
578+
ctx := context.Background()
579+
mockAuth := new(MockAuthService)
580+
setupAdminAuth(ctx, mockAuth)
581+
582+
store := setupAdminMock(ctx)
583+
store.CountPendingExecutionsForAccountFn = func(_ context.Context, _ string) (int, error) { return 1, nil }
584+
store.ListPendingExecutionIDsForAccountFn = func(_ context.Context, _ string) ([]string, error) {
585+
return []string{"exec-1"}, nil
586+
}
587+
liveCalled := false
588+
store.CountLiveExecutionsForAccountFn = func(_ context.Context, _ string) (int, error) {
589+
liveCalled = true
590+
return 4, nil
591+
}
592+
handler := &Handler{auth: mockAuth, config: store}
593+
594+
_, err := handler.deleteAccount(ctx, adminRequest(""), "11111111-1111-1111-1111-111111111111")
595+
ce, ok := IsClientError(err)
596+
require.True(t, ok, "expected ClientError, got %v", err)
597+
assert.Equal(t, 409, ce.code)
598+
assert.Equal(t, "pending_executions", ce.Details()["reason"])
599+
assert.False(t, liveCalled, "the live count is not needed once pending rows already block")
600+
}
601+
602+
// TestDeleteAccount_OnlyTerminalExecutions_Deletes asserts the issue #265
603+
// happy path at the handler: no pending and no live rows means the delete
604+
// goes through (DeleteCloudAccount detaches the terminal history itself).
605+
func TestDeleteAccount_OnlyTerminalExecutions_Deletes(t *testing.T) {
606+
ctx := context.Background()
607+
mockAuth := new(MockAuthService)
608+
setupAdminAuth(ctx, mockAuth)
609+
610+
store := setupAdminMock(ctx)
611+
store.CountPendingExecutionsForAccountFn = func(_ context.Context, _ string) (int, error) { return 0, nil }
612+
liveCalled := false
613+
store.CountLiveExecutionsForAccountFn = func(_ context.Context, _ string) (int, error) {
614+
liveCalled = true
615+
return 0, nil
616+
}
617+
deleteCalled := false
618+
store.DeleteCloudAccountFn = func(_ context.Context, _ string) error {
619+
deleteCalled = true
620+
return nil
621+
}
622+
handler := &Handler{auth: mockAuth, config: store}
623+
624+
result, err := handler.deleteAccount(ctx, adminRequest(""), "11111111-1111-1111-1111-111111111111")
625+
require.NoError(t, err)
626+
assert.Nil(t, result)
627+
assert.True(t, liveCalled, "the live preflight must run before the delete")
628+
assert.True(t, deleteCalled)
629+
}
630+
631+
// TestDeleteAccount_LiveCountErr_Returns500 asserts a failing live count is
632+
// surfaced as an error (500) and never falls through to the delete.
633+
func TestDeleteAccount_LiveCountErr_Returns500(t *testing.T) {
634+
ctx := context.Background()
635+
mockAuth := new(MockAuthService)
636+
setupAdminAuth(ctx, mockAuth)
637+
638+
store := setupAdminMock(ctx)
639+
store.CountPendingExecutionsForAccountFn = func(_ context.Context, _ string) (int, error) { return 0, nil }
640+
store.CountLiveExecutionsForAccountFn = func(_ context.Context, _ string) (int, error) {
641+
return 0, errors.New("db down")
642+
}
643+
deleteCalled := false
644+
store.DeleteCloudAccountFn = func(_ context.Context, _ string) error {
645+
deleteCalled = true
646+
return nil
647+
}
648+
handler := &Handler{auth: mockAuth, config: store}
649+
650+
_, err := handler.deleteAccount(ctx, adminRequest(""), "11111111-1111-1111-1111-111111111111")
651+
require.Error(t, err)
652+
_, ok := IsClientError(err)
653+
assert.False(t, ok, "a store failure must not be reported as a client error")
654+
assert.Contains(t, err.Error(), "db down")
655+
assert.False(t, deleteCalled)
656+
}
657+
533658
// TestDeleteAccount_PendingListErr_StillReturns409 asserts that a failure
534659
// listing execution_ids doesn't downgrade the 409 to a 500 — the operator
535660
// still gets the structured count and the explicit "cancel first" ask,
@@ -598,6 +723,93 @@ func TestDeleteAccount_FKViolationRace_Returns409(t *testing.T) {
598723
assert.Equal(t, "pending_executions", details["reason"])
599724
}
600725

726+
// TestDeleteAccount_FKViolationRace_RequeriesPreflight asserts that after a
727+
// 23503 race the handler re-runs the preflight, so the client gets the
728+
// specific structured 409 for whatever row raced in: pending (with its count)
729+
// or active (approved/running/scheduled/paused), not a generic pending 409.
730+
func TestDeleteAccount_FKViolationRace_RequeriesPreflight(t *testing.T) {
731+
cases := []struct {
732+
name string
733+
pending int
734+
live int
735+
wantReason string
736+
wantKey string
737+
}{
738+
{"pending row raced in", 2, 0, "pending_executions", "pending_count"},
739+
{"active row raced in", 0, 1, "active_executions", "active_count"},
740+
}
741+
for _, tc := range cases {
742+
t.Run(tc.name, func(t *testing.T) {
743+
ctx := context.Background()
744+
mockAuth := new(MockAuthService)
745+
setupAdminAuth(ctx, mockAuth)
746+
747+
store := setupAdminMock(ctx)
748+
pendingCalls, liveCalls := 0, 0
749+
// First call is the preflight (nothing yet); the second is the
750+
// re-query after the FK violation, which sees the raced-in row.
751+
store.CountPendingExecutionsForAccountFn = func(_ context.Context, _ string) (int, error) {
752+
pendingCalls++
753+
if pendingCalls == 1 {
754+
return 0, nil
755+
}
756+
return tc.pending, nil
757+
}
758+
store.CountLiveExecutionsForAccountFn = func(_ context.Context, _ string) (int, error) {
759+
liveCalls++
760+
if liveCalls == 1 {
761+
return 0, nil
762+
}
763+
return tc.live, nil
764+
}
765+
store.DeleteCloudAccountFn = func(_ context.Context, _ string) error {
766+
return &pgconn.PgError{Code: "23503"}
767+
}
768+
handler := &Handler{auth: mockAuth, config: store}
769+
770+
_, err := handler.deleteAccount(ctx, adminRequest(""), "11111111-1111-1111-1111-111111111111")
771+
require.Error(t, err)
772+
ce, ok := IsClientError(err)
773+
require.True(t, ok, "expected ClientError (409), got %T: %v", err, err)
774+
assert.Equal(t, 409, ce.code)
775+
details := ce.Details()
776+
require.NotNil(t, details)
777+
assert.Equal(t, tc.wantReason, details["reason"])
778+
assert.Contains(t, details, tc.wantKey, "re-queried 409 carries the count")
779+
})
780+
}
781+
}
782+
783+
// TestDeleteAccount_FKViolationRace_RequeryErrorFallsBack asserts that if the
784+
// re-query itself fails, the handler still returns the bare pending 409
785+
// rather than surfacing the re-query's 500.
786+
func TestDeleteAccount_FKViolationRace_RequeryErrorFallsBack(t *testing.T) {
787+
ctx := context.Background()
788+
mockAuth := new(MockAuthService)
789+
setupAdminAuth(ctx, mockAuth)
790+
791+
store := setupAdminMock(ctx)
792+
calls := 0
793+
store.CountPendingExecutionsForAccountFn = func(_ context.Context, _ string) (int, error) {
794+
calls++
795+
if calls == 1 {
796+
return 0, nil
797+
}
798+
return 0, errors.New("connection reset")
799+
}
800+
store.DeleteCloudAccountFn = func(_ context.Context, _ string) error {
801+
return &pgconn.PgError{Code: "23503"}
802+
}
803+
handler := &Handler{auth: mockAuth, config: store}
804+
805+
_, err := handler.deleteAccount(ctx, adminRequest(""), "11111111-1111-1111-1111-111111111111")
806+
require.Error(t, err)
807+
ce, ok := IsClientError(err)
808+
require.True(t, ok, "expected ClientError (409), got %T: %v", err, err)
809+
assert.Equal(t, 409, ce.code)
810+
assert.Equal(t, "pending_executions", ce.Details()["reason"])
811+
}
812+
601813
// TestDeleteAccount_NonFKError_StillReturns500 asserts that errors which
602814
// are NOT FK violations continue to bubble up as wrapped errors (500),
603815
// so we don't accidentally swallow real failures as 409s.

‎internal/config/interfaces.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,13 @@ type StoreInterface interface {
208208
// 409 with a count so the frontend can offer Cancel-All-Then-Delete UX
209209
// instead of surfacing a raw constraint error. See issue #606.
210210
CountPendingExecutionsForAccount(ctx context.Context, accountID string) (int, error)
211+
// CountLiveExecutionsForAccount returns the number of purchase_executions
212+
// referencing the given cloud account whose status is not in
213+
// TerminalExecutionStatuses (pending, notified, approved, running,
214+
// scheduled, paused, or any status not yet classified). These are the
215+
// rows that block DeleteCloudAccount; terminal rows are detached and kept
216+
// as history instead (issue #265).
217+
CountLiveExecutionsForAccount(ctx context.Context, accountID string) (int, error)
211218
// ListPendingExecutionIDsForAccount returns the execution IDs of all
212219
// pending / notified executions referencing this account, used by the
213220
// frontend's Cancel-All-Then-Delete flow when the operator opts to

0 commit comments

Comments
 (0)