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_deletionis now valid fromcreation_in_progress,transfer_in_progress,transfer_scheduledandmaintenance; removed the matching rejected-transition rows.state_preservation_spec.rb:start_deletiondoes not preserve from-states thatreschedule_deletioncannot restore.transition_validation_spec.rb: parent intransfer_in_progressblocksschedule_deletion; added coverage forvalidate_ancestor_chain_statewithnamespace_state_propagationdisabled.destroy_service_spec.rb: regression tests for descendants in transient transfer states,reschedule_deletion!raising inside the rescue block, and failure logging with a nilcurrent_user.
References
- Closes #608541 (closed)
Screenshots or screen recordings
N/A, backend-only fix with no UI changes.
How to set up and validate locally
- Create a group hierarchy where a child group is in
transfer_in_progressstate:parent = create(:group, :deletion_scheduled) child = create(:group, parent: parent, state: :transfer_in_progress) user = parent.owners.first - Attempt to destroy the parent group:
Groups::DestroyService.new(parent, user).execute - Before fix: The child's
start_deletion!raisesStateMachines::InvalidTransition, the follow-upreschedule_deletion!raises too, and the parent silently reschedules indefinitely. - After fix: Both groups are destroyed.
- 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.