Skip to content

Commit 03c4e59

Browse files
committed
feat: add DelayedJobsRecover scheduled job to re-enqueue stuck service operations
Introduces a new periodic recovery job that scans permanently failed delayed_jobs and re-enqueues polling for service operations still in progress at the broker. Recovers cases where a transient DB connection error caused the polling job to fail permanently (max_attempts=1) while the broker operation was still running, leaving the service instance stuck in 'in progress' with no active poller.
1 parent d16757b commit 03c4e59

6 files changed

Lines changed: 100 additions & 2 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
module VCAP::CloudController
2+
module Jobs
3+
module Runtime
4+
class DelayedJobsRecover < VCAP::CloudController::Jobs::CCJob
5+
RECOVERABLE_OPERATIONS = %w[
6+
service_instance.create
7+
].freeze
8+
9+
def perform
10+
logger.info('Recover halted delayed jobs')
11+
recover
12+
end
13+
14+
def max_attempts
15+
1
16+
end
17+
18+
private
19+
20+
def recover
21+
# find delayed jobs where failed_at is set (permanently failed)
22+
# and still within the max polling duration (not expired)
23+
cutoff_time = Time.now - default_maximum_duration_seconds
24+
dead_delayed_jobs = Delayed::Job.
25+
exclude(failed_at: nil).
26+
where { created_at > cutoff_time }.
27+
order(:created_at).
28+
limit(batch_size)
29+
30+
dead_delayed_jobs.each do |delayed|
31+
# pollable job state can be POLLING or FAILED depending on whether the failure
32+
# hook managed to persist before the db connection was lost
33+
pollable = PollableJobModel.where(delayed_job_guid: delayed.guid).
34+
where(state: [PollableJobModel::POLLING_STATE, PollableJobModel::FAILED_STATE]).
35+
first
36+
next unless pollable
37+
next unless RECOVERABLE_OPERATIONS.include?(pollable.operation)
38+
39+
# last_operation.state must be 'in progress'. This confirms the broker is still
40+
# working on the operation and CC is the one that gave up, not the broker
41+
entity = find_entity(pollable)
42+
next unless entity
43+
next unless entity.last_operation&.state == 'in progress'
44+
45+
reenqueue(pollable, delayed)
46+
end
47+
end
48+
49+
def find_entity(pollable)
50+
# TODO: resource_type field can be used
51+
case pollable.operation
52+
when 'service_instance.create'
53+
ManagedServiceInstance.first(guid: pollable.resource_guid)
54+
end
55+
end
56+
57+
def reenqueue(pollable, delayed)
58+
# re-verify atomically that the pollable job still points to this dead delayed_job.
59+
# if another process already re-enqueued a new job, pollable.delayed_job_guid was
60+
# updated to the new delayed_job's guid, so where clause returns nil and we skip safely.
61+
PollableJobModel.db.transaction do
62+
pjob = PollableJobModel.where(guid: pollable.guid,
63+
delayed_job_guid: delayed.guid,
64+
state: [PollableJobModel::POLLING_STATE, PollableJobModel::FAILED_STATE]).
65+
for_update.first
66+
return unless pjob
67+
68+
# bring the record into a clean polling state
69+
pjob.update(cf_api_error: nil, state: PollableJobModel::POLLING_STATE)
70+
71+
# unwrap the serialized handler and re-enqueue via the reoccurring job
72+
inner_job = Jobs::Enqueuer.unwrap_job(delayed.payload_object)
73+
inner_job.send(:enqueue_next_job, pjob)
74+
end
75+
end
76+
77+
def default_maximum_duration_seconds
78+
Config.config.get(:broker_client_max_async_poll_duration_minutes).minutes
79+
end
80+
81+
def logger
82+
@logger ||= Steno.logger('cc.background')
83+
end
84+
85+
def batch_size
86+
10
87+
end
88+
end
89+
end
90+
end
91+
end

config/cloud_controller.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,8 @@ diego_sync:
375375
pending_droplets:
376376
frequency_in_seconds: 300
377377
expiration_in_seconds: 42
378-
378+
delayed_jobs_recover:
379+
frequency_in_seconds: 600
379380
pending_builds:
380381
expiration_in_seconds: 42
381382
frequency_in_seconds: 300

lib/cloud_controller/clock/scheduler.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ class Scheduler
2424
{ name: 'pending_droplets', class: Jobs::Runtime::PendingDropletCleanup },
2525
{ name: 'pending_builds', class: Jobs::Runtime::PendingBuildCleanup },
2626
{ name: 'failed_jobs', class: Jobs::Runtime::FailedJobsCleanup },
27-
{ name: 'service_operations_initial_cleanup', class: Jobs::Runtime::ServiceOperationsInitialCleanup }
27+
{ name: 'service_operations_initial_cleanup', class: Jobs::Runtime::ServiceOperationsInitialCleanup },
28+
{ name: 'delayed_jobs_recover', class: Jobs::Runtime::DelayedJobsRecover }
2829
].freeze
2930

3031
def initialize(config)

lib/cloud_controller/config_schemas/clock_schema.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class ClockSchema < VCAP::Config
3434
completed_tasks: {
3535
cutoff_age_in_days: Integer
3636
},
37+
delayed_jobs_recover: {
38+
frequency_in_seconds: Integer
39+
},
3740
default_health_check_timeout: Integer,
3841

3942
uaa: {

lib/cloud_controller/jobs.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
require 'jobs/runtime/expired_blob_cleanup'
2626
require 'jobs/runtime/expired_orphaned_blob_cleanup'
2727
require 'jobs/runtime/expired_resource_cleanup'
28+
require 'jobs/runtime/delayed_jobs_recover'
2829
require 'jobs/runtime/failed_jobs_cleanup'
2930
require 'jobs/runtime/service_operations_initial_cleanup'
3031
require 'jobs/runtime/legacy_jobs'

lib/tasks/jobs.rake

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ namespace :jobs do
4949
'audit_events',
5050
'failed_jobs',
5151
'service_operations_initial_cleanup',
52+
'delayed_jobs_recover',
5253
'service_usage_events',
5354
'completed_tasks',
5455
'expired_blob_cleanup',

0 commit comments

Comments
 (0)