Fix group deletion stuck when descendant is in transient transfer state

What does this MR do and why?

Fixes a bug where group deletion can get stuck indefinitely when a descendant namespace is in a transient state (transfer_in_progress, transfer_scheduled, creation_in_progress or maintenance).

Root cause

Groups::DestroyService#unsafe_execute recursively destroys child groups synchronously. When a descendant is in a transient state, start_deletion! raises StateMachines::InvalidTransition because those states were not valid sources for start_deletion. The rescue block then calls reschedule_deletion!, which also raises for the same reason. That second exception replaces the original one, propagates to the parent, and the parent (still in deletion_in_progress) silently reschedules its own deletion. This repeats indefinitely until something external (e.g. StuckTransfersCancelCronWorker) clears the descendant's transient state.

Fix

1. Allow start_deletion from any state (app/models/concerns/namespaces/stateful.rb)

Descendant states are not propagated yet, and checking descendant states before scheduling deletion would need a query that requires an index on namespaces (which we can't add). So start_deletion now transitions from any state to deletion_in_progress, meaning a descendant in a transient state no longer blocks deletion of its ancestors. This can be tightened once state propagation lands.

2. Only preserve from-states that reschedule_deletion can restore (app/models/concerns/namespaces/stateful/state_preservation.rb)

Because start_deletion now fires from any state, state preservation could save a from-state like transfer_in_progress that reschedule_deletion can never restore, which would fail schema validation. Added RESTORABLE_FROM_STATES so start_deletion only preserves ancestor_inherited, archived and deletion_scheduled. Any other from-state lands in deletion_scheduled on reschedule.

3. Block schedule_deletion when the parent is transfer_in_progress (app/models/concerns/namespaces/stateful/transition_validation.rb)

Added transfer_in_progress to FORBIDDEN_ANCESTOR_STATES[:schedule_deletion] so a namespace can't be scheduled for deletion under a parent that is being transferred (the "Parent Check" from ADR 003). Also renamed add_forbidden_state_error to add_forbidden_ancestor_state_error for clarity.

4. Preserve the original exception in Groups::DestroyService (app/services/groups/destroy_service.rb)

Extracted the rescue logic into handle_destroy_failure. The reschedule_deletion call is wrapped in its own rescue StandardError so that if reschedule_deletion! itself raises, the original exception is still re-raised, the secondary error is logged separately and tracked with Gitlab::ErrorTracking. This prevents any future state-machine gaps from silently swallowing the real error.

Specs

  • stateful_spec.rb: start_deletion is now valid from creation_in_progress, transfer_in_progress, transfer_scheduled and maintenance; removed the matching rejected-transition rows.
  • state_preservation_spec.rb: start_deletion does not preserve from-states that reschedule_deletion cannot restore.
  • transition_validation_spec.rb: parent in transfer_in_progress blocks schedule_deletion; added coverage for validate_ancestor_chain_state with namespace_state_propagation disabled.
  • destroy_service_spec.rb: regression tests for descendants in transient transfer states, reschedule_deletion! raising inside the rescue block, and failure logging with a nil current_user.

References

Screenshots or screen recordings

N/A, backend-only fix with no UI changes.

How to set up and validate locally

  1. Create a group hierarchy where a child group is in transfer_in_progress state:
    parent = create(:group, :deletion_scheduled)
    child = create(:group, parent: parent, state: :transfer_in_progress)
    user = parent.owners.first
  2. Attempt to destroy the parent group:
    Groups::DestroyService.new(parent, user).execute
  3. Before fix: The child's start_deletion! raises StateMachines::InvalidTransition, the follow-up reschedule_deletion! raises too, and the parent silently reschedules indefinitely.
  4. After fix: Both groups are destroyed.
  5. Verify the new parent check:
    parent = create(:group, state: :transfer_in_progress)
    child = create(:group, parent: parent)
    child.schedule_deletion(transition_user: user) # => false
    child.errors[:state] # => ["cannot be changed as ancestor ID <parent.id> is transfer_in_progress"]

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Shubham Kumar

Merge request reports

Loading
Loading