Skip to content

Commit c52da16

Browse files
committed
Resructure mixin
1 parent 192e6eb commit c52da16

4 files changed

Lines changed: 221 additions & 146 deletions

File tree

app/jobs/mixins/recursive_delete_root_job_mixin.rb

Lines changed: 13 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
require 'errors/sub_resource_error'
22
require 'cloud_controller/errors/api_error'
33
require 'cloud_controller/errors/compound_error'
4+
require 'jobs/v3/sub_resource_failures'
45

56
module VCAP::CloudController
67
module Jobs
@@ -27,14 +28,15 @@ def perform_with_root_job_handling
2728

2829
yield
2930
rescue SubResourceError => e
31+
failures = sub_resource_failures
3032
if e.any_in_progress?
31-
log_immediate_failures(e.failures) # log failures that surfaced this run; async ones still polling defer us and are retried on the next run
33+
failures.log_immediate(e.failures) # async still polling: defer; the surfaced sync failures are retried next run
3234
return
3335
end
3436

35-
raise log_recursive_delete_failure(compound_error_for(e.failures)) # sync error occurred & no async job pending -> log and fail
37+
raise failures.log_and_return(failures.compound_error(e.failures)) # sync failure, nothing async pending
3638
rescue CloudController::Errors::CompoundError => e
37-
raise log_recursive_delete_failure(e) # async sub job failed -> log and fail
39+
raise sub_resource_failures.log_and_return(e) # an async sub-job failed terminally
3840
rescue CloudController::Errors::ApiError
3941
raise
4042
rescue StandardError => e
@@ -96,24 +98,15 @@ def sub_jobs_in_flight?
9698
def raise_if_sub_jobs_failed
9799
return unless sub_jobs.any? { |s| s.state == PollableJobModel::FAILED_STATE }
98100

99-
raise CloudController::Errors::CompoundError.new(all_failure_errors)
101+
raise sub_resource_failures.compound_error
100102
end
101103

102-
# Logs failures that surfaced during this run (a binding's unbind failed immediately rather than going
103-
# async-in-progress). Makes them visible to operators even though the run defers on the async ones,
104-
# which are retried on the next run.
105-
def log_immediate_failures(failures)
106-
failures.each do |error|
107-
logger.warn("#{display_name} #{resource_guid} (job #{root_job_guid}) sub-resource deletion failed: #{error.message}")
108-
end
109-
end
110-
111-
# Logs each underlying failure and returns the error so callers can `raise log_recursive_delete_failure(error)`.
112-
def log_recursive_delete_failure(error)
113-
error.underlying_errors.each do |underlying|
114-
logger.warn("#{display_name} #{resource_guid} (job #{root_job_guid}) sub-resource deletion failed: #{underlying.message}")
115-
end
116-
error
104+
# Rebuilt per call, never memoised: the job YAML-serialises itself on reschedule.
105+
def sub_resource_failures
106+
VCAP::CloudController::V3::SubResourceFailures.new(
107+
sub_jobs: sub_jobs, sub_resource_errors: sub_resource_errors, logger: logger,
108+
display_name: display_name, resource_guid: resource_guid, root_job_guid: root_job_guid
109+
)
117110
end
118111

119112
def add_in_progress_warning(job)
@@ -128,39 +121,10 @@ def in_progress_warning_detail
128121
'This operation is still in progress: it is waiting for one or more dependent operations to finish.'
129122
end
130123

131-
def compound_error_for(raised_failures)
132-
errors = all_failure_errors
133-
errors = raised_failures.map { |e| CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', e.message) } if errors.empty?
134-
CloudController::Errors::CompoundError.new(errors)
135-
end
136-
137-
def all_failure_errors
138-
by_guid = {}
139-
sub_resource_errors.each { |guid, err| by_guid[guid] = err }
140-
sub_job_errors.each { |guid, err| by_guid[guid] ||= err }
141-
by_guid.values
142-
end
143-
144-
def sub_job_errors
145-
sub_jobs.select { |s| s.state == PollableJobModel::FAILED_STATE }.map do |sub_job|
146-
[sub_job.resource_guid, CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', sub_job_error_detail(sub_job))]
147-
end
148-
end
149-
124+
# Host hook: [guid, ApiError] pairs for child resources that failed synchronously with no async sub-job.
150125
def sub_resource_errors
151126
[]
152127
end
153-
154-
def sub_job_error_detail(sub_job)
155-
identity = "#{sub_job.resource_type} #{sub_job.resource_guid}"
156-
return identity if sub_job.cf_api_error.nil?
157-
158-
parsed = Psych.safe_load(sub_job.cf_api_error, strict_integer: true)
159-
detail = parsed && parsed['errors']&.first&.fetch('detail', nil)
160-
detail.present? ? "#{identity}: #{detail}" : identity
161-
rescue Psych::Exception
162-
identity
163-
end
164128
end
165129
end
166130
end
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
require 'cloud_controller/errors/api_error'
2+
require 'cloud_controller/errors/compound_error'
3+
4+
module VCAP::CloudController
5+
module V3
6+
# Merges failed sub-jobs (async child rows) and failed sub-resources (sync unbinds, no sub-job row) into one error.
7+
# Never memoised onto the job: the job YAML-serialises itself on reschedule.
8+
class SubResourceFailures
9+
# sub_resource_errors: [guid, ApiError] pairs from the host job's sync-failure hook (empty for jobs with none).
10+
def initialize(sub_jobs:, sub_resource_errors:, logger:, display_name:, resource_guid:, root_job_guid:)
11+
@sub_jobs = sub_jobs
12+
@sub_resource_errors = sub_resource_errors
13+
@logger = logger
14+
@display_name = display_name
15+
@resource_guid = resource_guid
16+
@root_job_guid = root_job_guid
17+
end
18+
19+
# Falls back to the raised sync failures when nothing durable was recorded.
20+
def compound_error(raised_failures=[])
21+
errors = merged_errors
22+
errors = raised_failures.map { |e| CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', e.message) } if errors.empty?
23+
CloudController::Errors::CompoundError.new(errors)
24+
end
25+
26+
# Surfaces failures that failed immediately this run, so they are visible even though we defer on the async ones.
27+
def log_immediate(failures)
28+
failures.each { |error| log_failure(error.message) }
29+
end
30+
31+
def log_and_return(error)
32+
error.underlying_errors.each { |underlying| log_failure(underlying.message) }
33+
error
34+
end
35+
36+
private
37+
38+
def log_failure(message)
39+
@logger.warn("#{@display_name} #{@resource_guid} (job #{@root_job_guid}) sub-resource deletion failed: #{message}")
40+
end
41+
42+
def merged_errors
43+
by_guid = {}
44+
@sub_resource_errors.each { |guid, err| by_guid[guid] = err }
45+
sub_job_errors.each { |guid, err| by_guid[guid] ||= err }
46+
by_guid.values
47+
end
48+
49+
def sub_job_errors
50+
@sub_jobs.select { |s| s.state == PollableJobModel::FAILED_STATE }.map do |sub_job|
51+
[sub_job.resource_guid, CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', sub_job_error_detail(sub_job))]
52+
end
53+
end
54+
55+
def sub_job_error_detail(sub_job)
56+
identity = "#{sub_job.resource_type} #{sub_job.resource_guid}"
57+
return identity if sub_job.cf_api_error.nil?
58+
59+
parsed = Psych.safe_load(sub_job.cf_api_error, strict_integer: true)
60+
detail = parsed && parsed['errors']&.first&.fetch('detail', nil)
61+
detail.present? ? "#{identity}: #{detail}" : identity
62+
rescue Psych::Exception
63+
identity
64+
end
65+
end
66+
end
67+
end

spec/unit/jobs/mixins/recursive_delete_root_job_mixin_spec.rb

Lines changed: 25 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -318,95 +318,42 @@ def in_progress_warning_detail
318318
expect { job.send(:raise_if_sub_jobs_failed) }.not_to raise_error
319319
end
320320

321-
context 'when a settled sub-job has failed' do
322-
before do
323-
make_sub_job(state: PollableJobModel::FAILED_STATE,
324-
resource_type: 'service_credential_binding', resource_guid: 'binding-1',
325-
cf_api_error: YAML.dump({ 'errors' => [{ 'title' => 'CF-UnableToPerform', 'code' => 10_009,
326-
'detail' => 'unbind could not be completed: broker exploded' }] }))
327-
make_sub_job(state: PollableJobModel::COMPLETE_STATE)
328-
end
329-
330-
it 'raises a CompoundError of UnprocessableEntity carrying the failed sub-job detail' do
331-
job.send(:fetch_root_context)
332-
expect { job.send(:raise_if_sub_jobs_failed) }.to raise_error(CloudController::Errors::CompoundError) do |err|
333-
expect(err.underlying_errors.map(&:name)).to eq(%w[UnprocessableEntity])
334-
expect(err.underlying_errors.first.message).to include('unbind could not be completed: broker exploded')
335-
end
336-
end
337-
338-
context 'when the failed sub-job has no stored error detail' do
339-
before do
340-
make_sub_job(state: PollableJobModel::FAILED_STATE,
341-
resource_type: 'service_credential_binding', resource_guid: 'binding-2')
342-
end
343-
344-
it 'falls back to a resource reference for that entry' do
345-
job.send(:fetch_root_context)
346-
expect { job.send(:raise_if_sub_jobs_failed) }.to raise_error(CloudController::Errors::CompoundError) do |err|
347-
expect(err.underlying_errors.map(&:message)).to include(a_string_including('service_credential_binding binding-2'))
348-
end
349-
end
350-
end
321+
# The shape of the raised error (merge, dedup, detail parsing) is covered in sub_resource_failures_spec.
322+
it 'raises a CompoundError when a sub-job has settled failed' do
323+
make_sub_job(state: PollableJobModel::FAILED_STATE, resource_type: 'service_credential_binding', resource_guid: 'binding-1')
324+
job.send(:fetch_root_context)
325+
expect { job.send(:raise_if_sub_jobs_failed) }.to raise_error(CloudController::Errors::CompoundError)
351326
end
352327
end
353328

354329
describe 'sub_resource_errors (durable sync-failure hook)' do
355330
let!(:root_pollable_job) { make_root }
356331

332+
let(:job_with_sync_failure) do
333+
klass = Class.new(test_job_class) do
334+
def sub_resource_errors
335+
[['sync-binding', CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'sync unbind failed')]]
336+
end
337+
end
338+
klass.new('resource-guid-1')
339+
end
340+
357341
it 'defaults to none so jobs without sub-resources are unaffected' do
358342
make_sub_job(state: PollableJobModel::COMPLETE_STATE)
359343
job.send(:fetch_root_context)
360344
expect { job.send(:raise_if_sub_jobs_failed) }.not_to raise_error
361345
end
362346

363-
context 'when a subclass reports a failed sub-resource with no matching sub-job' do
364-
let(:job_with_sync_failure) do
365-
klass = Class.new(test_job_class) do
366-
def sub_resource_errors
367-
[['sync-binding', CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'sync unbind failed')]]
368-
end
369-
end
370-
klass.new('resource-guid-1')
371-
end
372-
373-
it 'does NOT halt when there is no failed sub-job, so the action can re-run and retry it' do
374-
job_with_sync_failure.send(:fetch_root_context)
375-
expect { job_with_sync_failure.send(:raise_if_sub_jobs_failed) }.not_to raise_error
376-
end
377-
378-
it 'is reported once a sub-job has terminally failed (action is then skipped), merged with that sub-job' do
379-
make_sub_job(state: PollableJobModel::FAILED_STATE,
380-
resource_type: 'service_credential_binding', resource_guid: 'async-binding',
381-
cf_api_error: YAML.dump({ 'errors' => [{ 'detail' => 'async unbind failed' }] }))
382-
job_with_sync_failure.send(:fetch_root_context)
383-
384-
expect { job_with_sync_failure.send(:raise_if_sub_jobs_failed) }.to raise_error(CloudController::Errors::CompoundError) do |err|
385-
expect(err.underlying_errors.map(&:message)).to include(a_string_including('sync unbind failed'), a_string_including('async unbind failed'))
386-
end
387-
end
347+
# Self-heal contract: a sync failure alone must not halt, so the action re-runs and retries it.
348+
it 'does NOT halt when a sub-resource failed but no sub-job has failed' do
349+
job_with_sync_failure.send(:fetch_root_context)
350+
expect { job_with_sync_failure.send(:raise_if_sub_jobs_failed) }.not_to raise_error
388351
end
389352

390-
context 'when a failed sub-resource shares its guid with a failed sub-job' do
391-
let(:job_with_dup) do
392-
klass = Class.new(test_job_class) do
393-
def sub_resource_errors
394-
[['shared-guid', CloudController::Errors::ApiError.new_from_details('UnprocessableEntity', 'unbind failed once')]]
395-
end
396-
end
397-
klass.new('resource-guid-1')
398-
end
399-
400-
it 'reports the resource only once' do
401-
make_sub_job(state: PollableJobModel::FAILED_STATE,
402-
resource_type: 'service_credential_binding', resource_guid: 'shared-guid',
403-
cf_api_error: YAML.dump({ 'errors' => [{ 'detail' => 'unbind failed once' }] }))
404-
job_with_dup.send(:fetch_root_context)
405-
406-
expect { job_with_dup.send(:raise_if_sub_jobs_failed) }.to raise_error(CloudController::Errors::CompoundError) do |err|
407-
expect(err.underlying_errors.size).to eq(1)
408-
end
409-
end
353+
it 'halts once a sub-job has terminally failed, feeding the hook into the raised error' do
354+
make_sub_job(state: PollableJobModel::FAILED_STATE, resource_type: 'service_credential_binding', resource_guid: 'async-binding')
355+
job_with_sync_failure.send(:fetch_root_context)
356+
expect { job_with_sync_failure.send(:raise_if_sub_jobs_failed) }.to raise_error(CloudController::Errors::CompoundError)
410357
end
411358
end
412359

@@ -503,32 +450,13 @@ def sub_resource_errors
503450
expect(logger).not_to have_received(:warn).with(a_string_including('sub-resource deletion failed'))
504451
end
505452

506-
it 'translates SubResourceError with real failures to a CompoundError of UnprocessableEntity' do
453+
# Routing only; the CompoundError's contents are covered in sub_resource_failures_spec.
454+
it 'translates a SubResourceError with real failures to a CompoundError' do
507455
expect do
508456
job.send(:perform_with_root_job_handling) do
509457
raise SubResourceError.new([StandardError.new('one broke'), StandardError.new('two broke')])
510458
end
511-
end.to raise_error(CloudController::Errors::CompoundError) do |err|
512-
expect(err.underlying_errors).to all(be_a(CloudController::Errors::ApiError))
513-
expect(err.underlying_errors.map(&:name)).to eq(%w[UnprocessableEntity UnprocessableEntity])
514-
expect(err.underlying_errors.map(&:message)).to include(match(/one broke/), match(/two broke/))
515-
end
516-
end
517-
518-
it 'merges current-tick sync failures with settled failed sub-jobs into one CompoundError' do
519-
make_sub_job(state: PollableJobModel::FAILED_STATE,
520-
resource_type: 'service_credential_binding', resource_guid: 'async-binding',
521-
cf_api_error: YAML.dump({ 'errors' => [{ 'title' => 'CF-UnableToPerform', 'code' => 10_009,
522-
'detail' => 'async unbind failed' }] }))
523-
524-
expect do
525-
job.send(:perform_with_root_job_handling) do
526-
raise SubResourceError.new([StandardError.new('sync unbind failed')])
527-
end
528-
end.to raise_error(CloudController::Errors::CompoundError) do |err|
529-
expect(err.underlying_errors.map(&:name)).to all(eq('UnprocessableEntity'))
530-
expect(err.underlying_errors.map(&:message)).to include(match(/sync unbind failed/), match(/async unbind failed/))
531-
end
459+
end.to raise_error(CloudController::Errors::CompoundError)
532460
end
533461

534462
it 'passes ApiErrors through unchanged' do

0 commit comments

Comments
 (0)