|
| 1 | +require 'spec_helper' |
| 2 | + |
| 3 | +module VCAP::CloudController |
| 4 | + module Jobs::Runtime |
| 5 | + RSpec.describe ServiceOperationsBindingDeleteStuckInProgressRetry, job_context: :worker do |
| 6 | + subject(:job) { ServiceOperationsBindingDeleteStuckInProgressRetry.new } |
| 7 | + |
| 8 | + let(:fake_logger) { instance_double(Steno::Logger, info: nil, warn: nil, error: nil) } |
| 9 | + let(:max_poll_duration_minutes) { 60 } |
| 10 | + let(:user_audit_info) { UserAuditInfo.new(user_guid: create(:user).guid, user_email: 'foo@example.com') } |
| 11 | + let(:enqueuer) { instance_double(Jobs::GenericEnqueuer, enqueue_pollable: nil) } |
| 12 | + |
| 13 | + before do |
| 14 | + allow(Steno).to receive(:logger).and_return(fake_logger) |
| 15 | + TestConfig.override(broker_client_max_async_poll_duration_minutes: max_poll_duration_minutes) |
| 16 | + allow(Jobs::GenericEnqueuer).to receive(:shared).and_return(enqueuer) |
| 17 | + end |
| 18 | + |
| 19 | + # Enqueue a real DeleteBindingJob so the delayed_job carries a genuine serialized handler, |
| 20 | + # then simulate the permanent failure (failed_at set) that leaves the operation stuck in progress. |
| 21 | + def prepare_stuck_binding( |
| 22 | + binding_type:, |
| 23 | + operation_state: 'in progress', |
| 24 | + operation_type: 'delete', |
| 25 | + operation_created_at: Time.now, |
| 26 | + pollable_job_state: PollableJobModel::FAILED_STATE, |
| 27 | + pollable_job_operation: nil, |
| 28 | + delayed_job_failed_at: Time.now |
| 29 | + ) |
| 30 | + if binding_type == :credential |
| 31 | + binding = create(:service_binding) |
| 32 | + create(:service_binding_operation, service_binding_id: binding.id, type: operation_type, state: operation_state, created_at: operation_created_at) |
| 33 | + default_operation = 'service_bindings.delete' |
| 34 | + resource_type = 'service_bindings' |
| 35 | + else |
| 36 | + binding = create(:service_key) |
| 37 | + create(:service_key_operation, service_key_id: binding.id, type: operation_type, state: operation_state, created_at: operation_created_at) |
| 38 | + default_operation = 'service_keys.delete' |
| 39 | + resource_type = 'service_keys' |
| 40 | + end |
| 41 | + |
| 42 | + delete_job = V3::DeleteBindingJob.new(binding_type, binding.guid, user_audit_info: user_audit_info) |
| 43 | + pjob = Jobs::Enqueuer.new(queue: Jobs::Queues.generic).enqueue_pollable(delete_job) |
| 44 | + pjob.update(state: pollable_job_state, operation: pollable_job_operation || default_operation, resource_type: resource_type) |
| 45 | + |
| 46 | + dj = Delayed::Job[guid: pjob.delayed_job_guid] |
| 47 | + dj.update(failed_at: delayed_job_failed_at) |
| 48 | + |
| 49 | + { binding: binding, pjob: pjob, delayed_job: dj } |
| 50 | + end |
| 51 | + |
| 52 | + it { is_expected.to be_a_valid_job } |
| 53 | + |
| 54 | + %i[credential key].each do |binding_type| |
| 55 | + describe "#perform for #{binding_type} bindings" do |
| 56 | + shared_examples 'does not retry the operation' do |
| 57 | + it 'leaves the operation in progress, the pollable job untouched, and does not re-enqueue' do |
| 58 | + scenario = subject_scenario |
| 59 | + original_pollable_state = scenario[:pjob].state |
| 60 | + job.perform |
| 61 | + expect(scenario[:binding].last_operation.reload.state).to eq('in progress') |
| 62 | + expect(scenario[:pjob].reload.state).to eq(original_pollable_state) |
| 63 | + expect(enqueuer).not_to have_received(:enqueue_pollable) |
| 64 | + end |
| 65 | + end |
| 66 | + |
| 67 | + context 'when operation state is not in progress' do |
| 68 | + it 'does not retry when state is succeeded' do |
| 69 | + scenario = prepare_stuck_binding(binding_type: binding_type, operation_state: 'succeeded') |
| 70 | + job.perform |
| 71 | + expect(scenario[:binding].last_operation.reload.state).to eq('succeeded') |
| 72 | + expect(enqueuer).not_to have_received(:enqueue_pollable) |
| 73 | + end |
| 74 | + |
| 75 | + it 'does not retry when state is failed' do |
| 76 | + scenario = prepare_stuck_binding(binding_type: binding_type, operation_state: 'failed') |
| 77 | + job.perform |
| 78 | + expect(scenario[:binding].last_operation.reload.state).to eq('failed') |
| 79 | + expect(enqueuer).not_to have_received(:enqueue_pollable) |
| 80 | + end |
| 81 | + end |
| 82 | + |
| 83 | + context 'when operation type is not delete' do |
| 84 | + let(:subject_scenario) do |
| 85 | + prepare_stuck_binding(binding_type: binding_type, operation_type: 'create', |
| 86 | + pollable_job_operation: binding_type == :credential ? 'service_bindings.create' : 'service_keys.create') |
| 87 | + end |
| 88 | + |
| 89 | + it_behaves_like 'does not retry the operation' |
| 90 | + end |
| 91 | + |
| 92 | + context 'when operation created_at is beyond the max polling window' do |
| 93 | + let(:subject_scenario) { prepare_stuck_binding(binding_type: binding_type, operation_created_at: Time.now - (max_poll_duration_minutes + 1).minutes) } |
| 94 | + |
| 95 | + it_behaves_like 'does not retry the operation' |
| 96 | + end |
| 97 | + |
| 98 | + context 'when delayed_job.failed_at is nil (job still running or locked)' do |
| 99 | + let(:subject_scenario) { prepare_stuck_binding(binding_type: binding_type, delayed_job_failed_at: nil) } |
| 100 | + |
| 101 | + it_behaves_like 'does not retry the operation' |
| 102 | + end |
| 103 | + |
| 104 | + context 'when pollable job state is COMPLETE' do |
| 105 | + let(:subject_scenario) { prepare_stuck_binding(binding_type: binding_type, pollable_job_state: PollableJobModel::COMPLETE_STATE) } |
| 106 | + |
| 107 | + it_behaves_like 'does not retry the operation' |
| 108 | + end |
| 109 | + |
| 110 | + context 'when pollable job state is PROCESSING' do |
| 111 | + let(:subject_scenario) { prepare_stuck_binding(binding_type: binding_type, pollable_job_state: PollableJobModel::PROCESSING_STATE) } |
| 112 | + |
| 113 | + it_behaves_like 'does not retry the operation' |
| 114 | + end |
| 115 | + |
| 116 | + context 'when pollable job operation does not match the delete operation' do |
| 117 | + let(:subject_scenario) do |
| 118 | + prepare_stuck_binding(binding_type: binding_type, |
| 119 | + pollable_job_operation: binding_type == :credential ? 'service_bindings.create' : 'service_keys.create') |
| 120 | + end |
| 121 | + |
| 122 | + it_behaves_like 'does not retry the operation' |
| 123 | + end |
| 124 | + |
| 125 | + context 'when a binding delete job is stuck with state FAILED' do |
| 126 | + it 'resets the pollable job to POLLING and re-enqueues the original delete job' do |
| 127 | + scenario = prepare_stuck_binding(binding_type: binding_type) |
| 128 | + job.perform |
| 129 | + |
| 130 | + expect(scenario[:binding].last_operation.reload.state).to eq('in progress') |
| 131 | + expect(scenario[:pjob].reload.state).to eq(PollableJobModel::POLLING_STATE) |
| 132 | + expect(enqueuer).to have_received(:enqueue_pollable).with( |
| 133 | + an_instance_of(V3::DeleteBindingJob), |
| 134 | + hash_including(existing_guid: scenario[:pjob].guid, preserve_priority: true) |
| 135 | + ) |
| 136 | + end |
| 137 | + end |
| 138 | + |
| 139 | + context 'when a binding delete job is stuck with state POLLING (DB flip before failure hook)' do |
| 140 | + it 'resets the pollable job to POLLING and re-enqueues the original delete job' do |
| 141 | + scenario = prepare_stuck_binding(binding_type: binding_type, pollable_job_state: PollableJobModel::POLLING_STATE) |
| 142 | + job.perform |
| 143 | + |
| 144 | + expect(scenario[:pjob].reload.state).to eq(PollableJobModel::POLLING_STATE) |
| 145 | + expect(enqueuer).to have_received(:enqueue_pollable).with( |
| 146 | + an_instance_of(V3::DeleteBindingJob), |
| 147 | + hash_including(existing_guid: scenario[:pjob].guid) |
| 148 | + ) |
| 149 | + end |
| 150 | + end |
| 151 | + |
| 152 | + context 'when there are multiple stuck jobs within the batch size' do |
| 153 | + it 'retries each one' do |
| 154 | + 3.times { prepare_stuck_binding(binding_type: binding_type) } |
| 155 | + job.perform |
| 156 | + expect(enqueuer).to have_received(:enqueue_pollable).exactly(3).times |
| 157 | + end |
| 158 | + end |
| 159 | + |
| 160 | + context 'when there are more stuck jobs than the batch size' do |
| 161 | + it 'processes only up to BATCH_SIZE jobs per run' do |
| 162 | + (ServiceOperationsBindingDeleteStuckInProgressRetry::BATCH_SIZE + 1).times { prepare_stuck_binding(binding_type: binding_type) } |
| 163 | + job.perform |
| 164 | + expect(enqueuer).to have_received(:enqueue_pollable).exactly(ServiceOperationsBindingDeleteStuckInProgressRetry::BATCH_SIZE).times |
| 165 | + end |
| 166 | + end |
| 167 | + end |
| 168 | + end |
| 169 | + |
| 170 | + describe '#perform cross-type isolation' do |
| 171 | + it 'retries both a stuck credential-binding delete and a stuck key delete' do |
| 172 | + prepare_stuck_binding(binding_type: :credential) |
| 173 | + prepare_stuck_binding(binding_type: :key) |
| 174 | + job.perform |
| 175 | + expect(enqueuer).to have_received(:enqueue_pollable).exactly(2).times |
| 176 | + end |
| 177 | + end |
| 178 | + |
| 179 | + describe '#resolve_stuck' do |
| 180 | + context 'when another process already resolved it (skip_locked returns nil)' do |
| 181 | + it 'does nothing and does not re-enqueue' do |
| 182 | + scenario = prepare_stuck_binding(binding_type: :credential) |
| 183 | + |
| 184 | + expect do |
| 185 | + job.send(:resolve_stuck, ServiceBindingOperation, ServiceBinding, |
| 186 | + -1, scenario[:binding].id, scenario[:pjob].guid) |
| 187 | + end.not_to raise_error |
| 188 | + expect(scenario[:pjob].reload.state).to eq(PollableJobModel::FAILED_STATE) |
| 189 | + expect(enqueuer).not_to have_received(:enqueue_pollable) |
| 190 | + end |
| 191 | + end |
| 192 | + |
| 193 | + context 'when the delayed job handler cannot be deserialized' do |
| 194 | + it 'does not re-enqueue and leaves the pollable job untouched' do |
| 195 | + scenario = prepare_stuck_binding(binding_type: :credential) |
| 196 | + Delayed::Job[guid: scenario[:pjob].delayed_job_guid].update(handler: 'not-valid-yaml: ]') |
| 197 | + op = scenario[:binding].last_operation |
| 198 | + |
| 199 | + job.send(:resolve_stuck, ServiceBindingOperation, ServiceBinding, |
| 200 | + op.id, scenario[:binding].id, scenario[:pjob].guid) |
| 201 | + |
| 202 | + expect(scenario[:pjob].reload.state).to eq(PollableJobModel::FAILED_STATE) |
| 203 | + expect(enqueuer).not_to have_received(:enqueue_pollable) |
| 204 | + end |
| 205 | + end |
| 206 | + |
| 207 | + context 'when the operation is stuck in progress' do |
| 208 | + it 'resets the pollable job from its failed state to POLLING' do |
| 209 | + scenario = prepare_stuck_binding(binding_type: :credential) |
| 210 | + op = scenario[:binding].last_operation |
| 211 | + |
| 212 | + expect do |
| 213 | + job.send(:resolve_stuck, ServiceBindingOperation, ServiceBinding, |
| 214 | + op.id, scenario[:binding].id, scenario[:pjob].guid) |
| 215 | + end.to change { scenario[:pjob].reload.state }.from(PollableJobModel::FAILED_STATE).to(PollableJobModel::POLLING_STATE) |
| 216 | + end |
| 217 | + end |
| 218 | + end |
| 219 | + end |
| 220 | + end |
| 221 | +end |
0 commit comments