Skip to content

Commit a87ea6c

Browse files
committed
Add async recursive delete for apps and service instances
Behind `temporary_enable_async_recursive_delete flag` (default off). Recursive delete jobs re-enqueue instead of failing when sub-resources (service bindings) are deleted asynchronously, waiting for them to settle and surfacing the original broker error on failure. Adds root/sub job tracking via `root_job_guid` on the jobs table. Foundation for future recursive deletes (org, space).
1 parent e203526 commit a87ea6c

41 files changed

Lines changed: 2140 additions & 161 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/actions/app_delete.rb

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,12 @@
1313
require 'actions/route_mapping_delete'
1414
require 'actions/staging_cancel'
1515
require 'actions/mixins/bindings_delete'
16+
require 'errors/sub_resource_error'
1617

1718
module VCAP::CloudController
1819
class AppDelete
1920
include V3::BindingsDeleteMixin
2021

21-
class AsyncBindingDeletionsTriggered < StandardError; end
22-
23-
class SubResourceError < StandardError
24-
def initialize(errors)
25-
@errors = errors
26-
end
27-
28-
def underlying_errors
29-
@errors
30-
end
31-
end
32-
3322
def initialize(user_audit_info)
3423
@user_audit_info = user_audit_info
3524
end
@@ -86,7 +75,7 @@ def delete_subresources(app)
8675

8776
def delete_non_transactional_subresources(app)
8877
errors = delete_bindings(app.service_bindings, user_audit_info: @user_audit_info)
89-
raise SubResourceError.new(errors) if errors.any?
78+
SubResourceError.raise_from(errors)
9079
end
9180

9281
def stagers
@@ -106,10 +95,8 @@ def logger
10695
@logger ||= Steno.logger('cc.action.app_delete')
10796
end
10897

109-
def unbinding_operation_in_progress!(binding)
110-
raise AsyncBindingDeletionsTriggered.new(
111-
"An operation for the service binding between app #{binding.app.name} and service instance #{binding.service_instance.name} is in progress."
112-
)
98+
def unbinding_in_progress_message(binding)
99+
"An operation for the service binding between app #{binding.app.name} and service instance #{binding.service_instance.name} is in progress."
113100
end
114101
end
115102
end

app/actions/app_stop.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ class AppStop
33
class InvalidApp < StandardError; end
44

55
class << self
6-
def stop(app:, user_audit_info:, record_event: true)
6+
def stop(app:, user_audit_info:, record_event: true, delete_triggered: false)
77
app.db.transaction do
88
app.lock!
99

@@ -13,7 +13,7 @@ def stop(app:, user_audit_info:, record_event: true)
1313
process.update(state: ProcessModel::STOPPED)
1414
end
1515

16-
record_audit_event(app, user_audit_info) if record_event
16+
record_audit_event(app, user_audit_info, delete_triggered:) if record_event
1717
end
1818
rescue Sequel::ValidationFailed => e
1919
raise InvalidApp.new(e.message)
@@ -25,10 +25,11 @@ def stop_without_event(app)
2525

2626
private
2727

28-
def record_audit_event(app, user_audit_info)
28+
def record_audit_event(app, user_audit_info, delete_triggered: false)
2929
Repositories::AppEventRepository.new.record_app_stop(
3030
app,
31-
user_audit_info
31+
user_audit_info,
32+
delete_triggered:
3233
)
3334
end
3435
end

app/actions/mixins/bindings_delete.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require 'jobs/generic_enqueuer'
44
require 'jobs/v3/delete_binding_job'
55
require 'jobs/v3/delete_service_binding_job_factory'
6+
require 'errors/sub_resource_error'
67

78
module VCAP::CloudController
89
module V3
@@ -21,12 +22,16 @@ def delete_bindings(bindings, user_audit_info:)
2122
unless result[:finished]
2223
polling_job = DeleteBindingJob.new(type, binding.guid, user_audit_info:)
2324
Jobs::GenericEnqueuer.shared.enqueue_pollable(polling_job)
24-
unbinding_operation_in_progress!(binding)
25+
raise AsyncOperationInProgress.new(unbinding_in_progress_message(binding))
2526
end
2627
rescue StandardError => e
2728
errors << e
2829
end
2930
end
31+
32+
def unbinding_in_progress_message(binding)
33+
"An operation for service binding #{binding.guid} is in progress."
34+
end
3035
end
3136
end
3237
end
Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require 'actions/service_credential_binding_delete'
22
require 'actions/mixins/bindings_delete'
3+
require 'errors/sub_resource_error'
34

45
module VCAP::CloudController
56
class ServiceInstanceUnshare
@@ -8,11 +9,15 @@ class ServiceInstanceUnshare
89
class Error < ::StandardError
910
end
1011

11-
def unshare(service_instance, target_space, user_audit_info)
12+
def unshare(service_instance, target_space, user_audit_info, fail_if_in_progress: true)
1213
errors = delete_bindings_in_target_space!(service_instance, target_space, user_audit_info)
1314
if errors.any?
14-
error!("Unshare of service instance failed because one or more bindings could not be deleted.\n\n " \
15-
"#{errors.map { |err| "\t#{err.message}" }.join("\n\n")}")
15+
if fail_if_in_progress
16+
raise Error.new("Unshare of service instance failed because one or more bindings could not be deleted.\n\n " \
17+
"#{errors.map { |err| "\t#{err.message}" }.join("\n\n")}")
18+
else
19+
SubResourceError.raise_from(errors)
20+
end
1621
end
1722

1823
service_instance.remove_shared_space(target_space)
@@ -24,19 +29,15 @@ def unshare(service_instance, target_space, user_audit_info)
2429

2530
private
2631

27-
def error!(message)
28-
raise Error.new(message)
29-
end
30-
3132
def delete_bindings_in_target_space!(service_instance, target_space, user_audit_info)
3233
active_bindings = ServiceBinding.where(service_instance_guid: service_instance.guid)
3334
bindings_in_target_space = active_bindings.all.select { |b| b.app.space_guid == target_space.guid }
3435
delete_bindings(bindings_in_target_space, user_audit_info:)
3536
end
3637

37-
def unbinding_operation_in_progress!(binding)
38-
raise Error.new("The binding between an application and service instance #{binding.service_instance.name} " \
39-
"in space #{binding.app.space.name} is being deleted asynchronously.")
38+
def unbinding_in_progress_message(binding)
39+
"The binding between an application and service instance #{binding.service_instance.name} " \
40+
"in space #{binding.app.space.name} is being deleted asynchronously."
4041
end
4142
end
4243
end

app/actions/v3/service_instance_delete.rb

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require 'actions/service_instance_unshare'
55
require 'cloud_controller/errors/api_error'
66
require 'actions/mixins/bindings_delete'
7+
require 'errors/sub_resource_error'
78

89
module VCAP::CloudController
910
module V3
@@ -13,9 +14,6 @@ class ServiceInstanceDelete
1314
class DeleteFailed < StandardError
1415
end
1516

16-
class UnbindingOperatationInProgress < StandardError
17-
end
18-
1917
DeleteStatus = Struct.new(:finished, :operation).freeze
2018
DeleteStarted = ->(operation) { DeleteStatus.new(false, operation) }
2119
DeleteComplete = DeleteStatus.new(true, nil).freeze
@@ -24,9 +22,10 @@ class UnbindingOperatationInProgress < StandardError
2422
PollingFinished = PollingStatus.new(true, nil).freeze
2523
ContinuePolling = ->(retry_after) { PollingStatus.new(false, retry_after) }
2624

27-
def initialize(service_instance, event_repo)
25+
def initialize(service_instance, event_repo, fail_if_in_progress: true)
2826
@service_instance = service_instance
2927
@service_event_repository = event_repo
28+
@fail_if_in_progress = fail_if_in_progress
3029
end
3130

3231
def blocking_operation_in_progress?
@@ -38,7 +37,11 @@ def delete
3837
operation_in_progress! if blocking_operation_in_progress?
3938

4039
errors = remove_associations
41-
raise errors.first if errors.any?
40+
if errors.any?
41+
raise errors.first if @fail_if_in_progress # Single-shot callers fail on the first error
42+
43+
SubResourceError.raise_from(errors)
44+
end
4245

4346
result = send_deprovison_to_broker
4447
if result[:finished]
@@ -49,6 +52,11 @@ def delete
4952
end
5053

5154
result
55+
rescue SubResourceError => e
56+
raise if !@fail_if_in_progress && e.any_in_progress? # re-raise SubResourceError so that root job continues to run
57+
58+
update_last_operation_with_failure(e.message) unless service_instance.operation_in_progress?
59+
raise e
5260
rescue StandardError => e
5361
update_last_operation_with_failure(e.message) unless service_instance.operation_in_progress?
5462
raise e
@@ -154,7 +162,9 @@ def unshare_all_spaces
154162

155163
unshare_action = ServiceInstanceUnshare.new
156164
space_guids.each_with_object([]) do |space_guid, errors|
157-
unshare_action.unshare(service_instance, Space.first(guid: space_guid), service_event_repository.user_audit_info)
165+
unshare_action.unshare(service_instance, Space.first(guid: space_guid), service_event_repository.user_audit_info, fail_if_in_progress: @fail_if_in_progress)
166+
rescue SubResourceError => e
167+
errors.concat(e.underlying_errors)
158168
rescue StandardError => e
159169
errors << e
160170
end
@@ -182,14 +192,12 @@ def operation_in_progress!
182192
raise CloudController::Errors::ApiError.new_from_details('AsyncServiceInstanceOperationInProgress', service_instance.name)
183193
end
184194

185-
def unbinding_operation_in_progress!(binding)
186-
raise UnbindingOperatationInProgress.new(
187-
if binding.is_a?(VCAP::CloudController::ServiceBinding)
188-
"An operation for the service binding between app #{binding.app.name} and service instance #{service_instance.name} is in progress."
189-
else
190-
"An operation for a service binding of service instance #{service_instance.name} is in progress."
191-
end
192-
)
195+
def unbinding_in_progress_message(binding)
196+
if binding.is_a?(VCAP::CloudController::ServiceBinding)
197+
"An operation for the service binding between app #{binding.app.name} and service instance #{service_instance.name} is in progress."
198+
else
199+
"An operation for a service binding of service instance #{service_instance.name} is in progress."
200+
end
193201
end
194202

195203
def delete_failed!(message)

app/controllers/runtime/apps_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require 'actions/v2/route_mapping_create'
77
require 'models/helpers/process_types'
88
require 'controllers/runtime/mixins/find_process_through_app'
9+
require 'errors/sub_resource_error'
910

1011
module VCAP::CloudController
1112
class AppsController < RestController::ModelController
@@ -148,7 +149,7 @@ def delete(guid)
148149
AppDelete.new(UserAuditInfo.from_context(SecurityContext)).delete_without_event([process.app])
149150
rescue Sequel::NoExistingObject
150151
raise self.class.not_found_exception(guid, AppModel)
151-
rescue AppDelete::SubResourceError => e
152+
rescue SubResourceError => e
152153
error_message = e.underlying_errors.map { |err| "\t" + err.message }.join("\n")
153154
raise CloudController::Errors::ApiError.new_from_details('AppRecursiveDeleteFailed', process.app.name, error_message)
154155
end

app/controllers/v3/apps_controller.rb

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
require 'actions/app_update'
66
require 'actions/app_patch_environment_variables'
77
require 'actions/app_delete'
8+
require 'errors/sub_resource_error'
89
require 'actions/app_restart'
910
require 'actions/app_apply_manifest'
1011
require 'actions/app_start'
1112
require 'actions/app_stop'
1213
require 'actions/app_assign_droplet'
14+
require 'jobs/v3/recursive_delete_app_job'
1315
require 'decorators/include_space_decorator'
1416
require 'decorators/include_organization_decorator'
1517
require 'decorators/include_space_organization_decorator'
@@ -154,12 +156,23 @@ def destroy
154156
unauthorized! unless permission_queryer.can_write_to_active_space?(space.id)
155157
require_writable_space!(space)
156158

157-
delete_action = AppDelete.new(user_audit_info)
158-
deletion_job = VCAP::CloudController::Jobs::DeleteActionJob.new(AppModel, app.guid, delete_action)
159+
if async_recursive_delete_enabled?
160+
job = Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_or_find_active_pollable(
161+
resource_model: AppModel,
162+
resource_guid: app.guid,
163+
operation: 'app.delete'
164+
) { VCAP::CloudController::V3::RecursiveDeleteAppJob.new(app.guid, user_audit_info) }
159165

160-
job = Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_pollable(deletion_job) do |pollable_job|
161-
DeleteAppErrorTranslatorJob.new(pollable_job)
166+
app_not_found! unless job
167+
else
168+
delete_action = AppDelete.new(user_audit_info)
169+
deletion_job = VCAP::CloudController::Jobs::DeleteActionJob.new(AppModel, app.guid, delete_action)
170+
171+
job = Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_pollable(deletion_job) do |pollable_job|
172+
DeleteAppErrorTranslatorJob.new(pollable_job)
173+
end
162174
end
175+
163176
VCAP::AppLogEmitter.emit(app.guid, "Enqueued job to delete app with guid #{app.guid}")
164177
head HTTP::ACCEPTED, 'Location' => url_builder.build_url(path: "/v3/jobs/#{job.guid}")
165178
end
@@ -372,7 +385,7 @@ class DeleteAppErrorTranslatorJob < VCAP::CloudController::Jobs::ErrorTranslator
372385
include V3ErrorsHelper
373386

374387
def translate_error(e)
375-
if e.instance_of?(VCAP::CloudController::AppDelete::SubResourceError)
388+
if e.instance_of?(VCAP::CloudController::SubResourceError)
376389
underlying_errors = e.underlying_errors.map { |err| unprocessable(err.message) }
377390
e = CloudController::Errors::CompoundError.new(underlying_errors)
378391
end
@@ -382,6 +395,10 @@ def translate_error(e)
382395

383396
private
384397

398+
def async_recursive_delete_enabled?
399+
!!VCAP::CloudController::Config.config.get(:temporary_enable_async_recursive_delete, :apps)
400+
end
401+
385402
def handle_order_by_presented_value(page_results)
386403
return unless page_results.try(:pagination_options).try(:order_by) == 'desired_state'
387404

app/controllers/v3/service_instances_controller.rb

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
require 'decorators/field_service_instance_plan_decorator'
3030
require 'jobs/v3/create_service_instance_job'
3131
require 'jobs/v3/update_service_instance_job'
32+
require 'jobs/v3/recursive_delete_service_instance_job'
3233

3334
class ServiceInstancesV3Controller < ApplicationController
3435
include ServicePermissions
@@ -115,13 +116,33 @@ def destroy
115116
end
116117

117118
delete_action = V3::ServiceInstanceDelete.new(service_instance, service_event_repository)
118-
operation_in_progress! if delete_action.blocking_operation_in_progress?
119119

120120
case service_instance
121121
when VCAP::CloudController::ManagedServiceInstance
122-
job_guid = enqueue_delete_job(service_instance)
123-
head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{job_guid}")
122+
if async_recursive_delete_enabled?
123+
# Idempotent retry: return the active delete job instead of 422-ing on its delete-in-progress last_operation.
124+
active_delete = PollableJobModel.find_active_delete(resource_guid: service_instance.guid, operation: 'service_instance.delete')
125+
operation_in_progress! if active_delete.nil? && delete_action.blocking_operation_in_progress?
126+
127+
job = Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_or_find_active_pollable(
128+
resource_model: ManagedServiceInstance,
129+
resource_guid: service_instance.guid,
130+
operation: 'service_instance.delete'
131+
) do |locked_instance|
132+
log_service_instance_deletion(locked_instance)
133+
V3::RecursiveDeleteServiceInstanceJob.new(locked_instance.guid, user_audit_info)
134+
end
135+
136+
service_instance_not_found! unless job
137+
138+
head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{job.guid}")
139+
else
140+
operation_in_progress! if delete_action.blocking_operation_in_progress?
141+
job_guid = enqueue_delete_job(service_instance)
142+
head :accepted, 'Location' => url_builder.build_url(path: "/v3/jobs/#{job_guid}")
143+
end
124144
when VCAP::CloudController::UserProvidedServiceInstance
145+
operation_in_progress! if delete_action.blocking_operation_in_progress?
125146
delete_action.delete
126147
head :no_content
127148
end
@@ -391,9 +412,7 @@ def fetch_writable_service_instance(guid)
391412
service_instance
392413
end
393414

394-
def enqueue_delete_job(service_instance)
395-
delete_job = V3::DeleteServiceInstanceJob.new(service_instance.guid, user_audit_info)
396-
415+
def log_service_instance_deletion(service_instance)
397416
plan = service_instance.service_plan
398417
service = plan.service
399418
broker = service.service_broker
@@ -404,11 +423,20 @@ def enqueue_delete_job(service_instance)
404423
"from service offering '#{service.label}' " \
405424
"provided by broker '#{broker.name}'."
406425
)
426+
end
407427

428+
# Legacy enqueue path used when the async-recursive-delete rollout flag is off. Preserves main's behaviour.
429+
def enqueue_delete_job(service_instance)
430+
log_service_instance_deletion(service_instance)
431+
delete_job = V3::DeleteServiceInstanceJob.new(service_instance.guid, user_audit_info)
408432
pollable_job = Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_pollable(delete_job)
409433
pollable_job.guid
410434
end
411435

436+
def async_recursive_delete_enabled?
437+
!!VCAP::CloudController::Config.config.get(:temporary_enable_async_recursive_delete, :service_instances)
438+
end
439+
412440
def unreadable_error_message(service_instance_name, unreadable_space_guids)
413441
return unless unreadable_space_guids.any?
414442

0 commit comments

Comments
 (0)