Add Organizations::HardDeleteService and HardDeleteWorker
What does this MR do and why?
Introduces the two-class service + worker pair that performs the actual hard-deletion of an organization (only allowed from soft_deleted), mirroring the Groups::DestroyService + GroupDestroyWorker pattern.
This is the second half of the lifecycle that Organizations::SoftDeleteService only starts: soft-delete moves the org to soft_deleted; this MR's HardDeleteService + HardDeleteWorker move it through deletion_in_progress and destroy the row.
For now, the service isn't called anywhere (hence unused), and isn't complete until Clean up `organization_users` in `Organizations... (#601755) is done, so I decided not to put it behind a feature flag for now.
Closes Add Organizations::HardDeletionService and Orga... (#594310 - closed) • Rémy Coutable • 19.1
Notable design points
- New state-machine event
abort_hard_deletion(deletion_in_progress → soft_deleted) so a faileddestroy!can return the row tosoft_deletedand let Sidekiq retry.ensure_transition_userextended to:hard_deleteand:abort_hard_deletionso callers can't drop the auditing metadata. #async_executetransitions todeletion_in_progressitself so the state acts as a marker that a worker has been scheduled; then enqueuesHardDeleteWorkerand logs the scheduling event.#executeaccepts bothsoft_deletedanddeletion_in_progressso it works on the worker path (state already transitioned) and as a direct call (e.g. from a Rails console). Thehard_delete!transition is guarded.- Rollback safety: the
abort_hard_deletion!call lives inside#abort_hard_deletion_safely(its ownrescue StandardError) so a rollback failure can't shadow the original error mid-flight. Both errors are logged separately; the original re-raises so Sidekiq can retry. - EE override emits an
organization_hard_deletedaudit event (Instance scope, streamed) with the matching audit_events type YAML. - Worker disables
Gitlab::QueryLimiting(the destroy cascade will exceed the 100-query budget once it's wired), logs info on skip cases (missing organization / user), and surfacesServiceResponseerror responses viaGitlab::AppLogger.warnso a failed deletion doesn't silently report success and leave the org stuck. - Companion rename
SoftDeletionService → SoftDeleteService(FOSS + EE + specs) for active-tense consistency with the newHardDeleteService.
Out of scope
- Cascading destruction of
organization_users(and any other org-owned associations). The current state ofOrganization#destroy!will fail on the existing FK constraint (fk_8471abad75); the service spec stubsdestroy!to verify the call without exercising the cascade. A follow-up will add an explicit cleanup step in#organization_destroy. - Publishing
Organizations::OrganizationDeletedEvent(deferred to a follow-up). - Any UI / controller / GraphQL entry point that triggers
#async_execute.
Test plan
- CI green on the new specs in `spec/services/organizations/hard_delete_service_spec.rb`, `spec/workers/organizations/hard_delete_worker_spec.rb`, `ee/spec/services/ee/organizations/hard_delete_service_spec.rb`, and the extended `spec/models/concerns/organizations/stateful_spec.rb`.
- Manual smoke against a GDK: ```ruby org = create(:organization) user = org.owners.first || create(:organization_user, :owner, organization: org).user Organizations::SoftDeleteService.new(org, current_user: user).execute Sidekiq::Testing.inline! { Organizations::HardDeleteService.new(org.reload, current_user: user).async_execute } Organizations::Organization.exists?(org.id) # => false (once the FK cascade follow-up lands) ```
- Failure-path check: stub `destroy!` to raise in a console session and confirm the org returns to `soft_deleted` and the original error is re-raised.