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 failed destroy! can return the row to soft_deleted and let Sidekiq retry. ensure_transition_user extended to :hard_delete and :abort_hard_deletion so callers can't drop the auditing metadata.
  • #async_execute transitions to deletion_in_progress itself so the state acts as a marker that a worker has been scheduled; then enqueues HardDeleteWorker and logs the scheduling event.
  • #execute accepts both soft_deleted and deletion_in_progress so it works on the worker path (state already transitioned) and as a direct call (e.g. from a Rails console). The hard_delete! transition is guarded.
  • Rollback safety: the abort_hard_deletion! call lives inside #abort_hard_deletion_safely (its own rescue 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_deleted audit 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 surfaces ServiceResponse error responses via Gitlab::AppLogger.warn so 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 new HardDeleteService.

Out of scope

  • Cascading destruction of organization_users (and any other org-owned associations). The current state of Organization#destroy! will fail on the existing FK constraint (fk_8471abad75); the service spec stubs destroy! 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.

🤖 Generated with Claude Code

Edited by Rémy Coutable

Merge request reports

Loading