Skip to content

Commit 850d809

Browse files
authored
Add lifecycle_type backfill clock job and rake task (#5196)
A one-off clock job that backfills the lifecycle_type column on apps, droplets, and builds for rows that pre-date the column's introduction. Once all installations have run it long enough to drain those rows, this job will be removed. The job processes up to 1000 (BATCH_SIZE) * 10 (BATCHES_PER_RUN) rows per table in short transactions, idempotent and bounded. It is defensive against missing columns: schema(:table) is checked on each invocation, so it would be safe to be shipped together with the migration that introduces the column. Operators can also run `rake db:lifecycle_type_backfill[batch_size, batches_per_run]` manually; passing -1 for batches_per_run drains until no NULL rows remain.
1 parent d1bf989 commit 850d809

8 files changed

Lines changed: 322 additions & 1 deletion

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# NOTE: This is a one-off backfill job. It populates the `lifecycle_type`
2+
# column on `apps`, `droplets`, and `builds` for rows that pre-date the
3+
# column's introduction. Once all installations have run it long enough to
4+
# drain those rows, this job will be removed.
5+
#
6+
# Operators can also run `rake db:lifecycle_type_backfill` manually.
7+
8+
module VCAP::CloudController
9+
module Jobs
10+
module Runtime
11+
class LifecycleTypeBackfill < VCAP::CloudController::Jobs::CCJob
12+
BATCH_SIZE = 1000
13+
BATCHES_PER_RUN = 10
14+
15+
TABLES = [
16+
{ table: :apps, guid_column: :app_guid },
17+
{ table: :droplets, guid_column: :droplet_guid },
18+
{ table: :builds, guid_column: :build_guid }
19+
].freeze
20+
21+
# Pass -1 for +batches_per_run+ to drain until no rows remain.
22+
def initialize(batch_size: BATCH_SIZE, batches_per_run: BATCHES_PER_RUN)
23+
super()
24+
@batch_size = batch_size
25+
@batches_per_run = batches_per_run
26+
end
27+
28+
def perform
29+
TABLES.each { |t| backfill(**t) }
30+
end
31+
32+
def job_name_in_configuration
33+
:lifecycle_type_backfill
34+
end
35+
36+
def max_attempts
37+
1
38+
end
39+
40+
private
41+
42+
def backfill(table:, guid_column:)
43+
return unless column_exists?(table, :lifecycle_type)
44+
45+
total_rows = 0
46+
remaining_batches = @batches_per_run
47+
while remaining_batches != 0 # -1 means: drain until no rows remain
48+
updated_rows = update_batch(table, guid_column)
49+
total_rows += updated_rows
50+
break if updated_rows < @batch_size
51+
52+
remaining_batches -= 1 if remaining_batches > 0
53+
end
54+
logger.info("lifecycle_type_backfill: updated #{total_rows} rows in #{table}") if total_rows > 0
55+
end
56+
57+
def update_batch(table, guid_column)
58+
guids = db[table].where(lifecycle_type: nil).limit(@batch_size).select_map(:guid)
59+
return 0 if guids.empty?
60+
61+
# If a row appears in both *_lifecycle_data tables (which it shouldn't), buildpack wins
62+
# (matches the runtime fallback in {app,build,droplet}_model.rb#lifecycle_type).
63+
guids_with_buildpack_lifecycle_data = db[:buildpack_lifecycle_data].where(guid_column => guids).select_map(guid_column)
64+
guids_with_cnb_lifecycle_data = db[:cnb_lifecycle_data].where(guid_column => guids).select_map(guid_column) - guids_with_buildpack_lifecycle_data
65+
guids_without_lifecycle_data = guids - guids_with_buildpack_lifecycle_data - guids_with_cnb_lifecycle_data
66+
67+
db.transaction do
68+
update_lifecycle(table, guids_with_buildpack_lifecycle_data, BuildpackLifecycleDataModel::LIFECYCLE_TYPE)
69+
update_lifecycle(table, guids_with_cnb_lifecycle_data, CNBLifecycleDataModel::LIFECYCLE_TYPE)
70+
update_lifecycle(table, guids_without_lifecycle_data, DockerLifecycleDataModel::LIFECYCLE_TYPE)
71+
end
72+
73+
guids.size
74+
end
75+
76+
def update_lifecycle(table, guids, value)
77+
return if guids.empty?
78+
79+
db[table].where(guid: guids, lifecycle_type: nil).update(lifecycle_type: value)
80+
end
81+
82+
def column_exists?(table, column)
83+
db.schema(table, reload: true).map(&:first).include?(column)
84+
rescue Sequel::Error
85+
false
86+
end
87+
88+
def db
89+
Sequel::Model.db
90+
end
91+
92+
def logger
93+
@logger ||= Steno.logger('cc.background.lifecycle-type-backfill')
94+
end
95+
end
96+
end
97+
end
98+
end

config/cloud_controller.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ service_operations_initial_cleanup:
5858
service_operations_create_in_progress_cleanup:
5959
frequency_in_seconds: 3600 #1h
6060

61+
# One-off backfill - to be removed in a future version.
62+
lifecycle_type_backfill:
63+
frequency_in_seconds: 3600 #1h
64+
6165
completed_tasks:
6266
cutoff_age_in_days: 31
6367

lib/cloud_controller/clock/scheduler.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ class Scheduler
2525
{ name: 'pending_builds', class: Jobs::Runtime::PendingBuildCleanup },
2626
{ name: 'failed_jobs', class: Jobs::Runtime::FailedJobsCleanup },
2727
{ name: 'service_operations_initial_cleanup', class: Jobs::Runtime::ServiceOperationsInitialCleanup },
28-
{ name: 'service_operations_create_in_progress_cleanup', class: Jobs::Runtime::ServiceOperationsCreateInProgressCleanup }
28+
{ name: 'service_operations_create_in_progress_cleanup', class: Jobs::Runtime::ServiceOperationsCreateInProgressCleanup },
29+
# One-off backfill - to be removed in a future version.
30+
{ name: 'lifecycle_type_backfill', class: Jobs::Runtime::LifecycleTypeBackfill }
2931
].freeze
3032

3133
def initialize(config)

lib/cloud_controller/config_schemas/clock_schema.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ class ClockSchema < VCAP::Config
3737
service_operations_create_in_progress_cleanup: {
3838
frequency_in_seconds: Integer
3939
},
40+
# One-off backfill - to be removed in a future version.
41+
lifecycle_type_backfill: {
42+
frequency_in_seconds: Integer
43+
},
4044
default_health_check_timeout: Integer,
4145

4246
uaa: {

lib/cloud_controller/jobs.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
require 'jobs/runtime/prune_completed_deployments'
3939
require 'jobs/runtime/prune_completed_builds'
4040
require 'jobs/runtime/prune_excess_app_revisions'
41+
require 'jobs/runtime/lifecycle_type_backfill'
4142

4243
require 'jobs/v2/services/service_usage_events_cleanup'
4344

lib/tasks/db.rake

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,27 @@ namespace :db do
192192
VCAP::BigintMigration.backfill(logger, db, args.table.to_sym, batch_size: args.batch_size.to_i, iterations: args.iterations.to_i)
193193
end
194194

195+
# One-off backfill - to be removed in a future version.
196+
desc 'Backfill lifecycle_type column on apps, droplets, and builds (pass -1 for batches_per_run to drain)'
197+
task :lifecycle_type_backfill, %i[batch_size batches_per_run] => :environment do |_t, args|
198+
args.with_defaults(batch_size: 1_000, batches_per_run: 10)
199+
200+
RakeConfig.context = :api
201+
202+
batch_size = args.batch_size.to_i
203+
batches_per_run = args.batches_per_run.to_i
204+
BackgroundJobEnvironment.new(RakeConfig.config).setup_environment do
205+
# Ensure we always log to stdout (regardless of `stdout_sink_enabled`).
206+
VCAP::CloudController::StenoConfigurer.new(RakeConfig.config.get(:logging)).configure do |steno_config_hash|
207+
steno_config_hash[:sinks] << Steno::Sink::IO.new($stdout)
208+
end
209+
logger = Steno.logger('cc.db.lifecycle_type_backfill')
210+
logger.info("starting lifecycle_type backfill (batch_size: #{batch_size}, batches_per_run: #{batches_per_run})")
211+
VCAP::CloudController::Jobs::Runtime::LifecycleTypeBackfill.new(batch_size:, batches_per_run:).perform
212+
logger.info('finished lifecycle_type backfill')
213+
end
214+
end
215+
195216
namespace :dev do
196217
desc 'Migrate the database set in spec/support/bootstrap/db_config'
197218
task migrate: :environment do
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
require 'spec_helper'
2+
3+
module VCAP::CloudController
4+
module Jobs::Runtime
5+
RSpec.describe LifecycleTypeBackfill, job_context: :worker do
6+
subject(:job) { LifecycleTypeBackfill.new }
7+
8+
let(:db) { Sequel::Model.db }
9+
10+
it { is_expected.to be_a_valid_job }
11+
12+
it 'knows its job name' do
13+
expect(job.job_name_in_configuration).to eq(:lifecycle_type_backfill)
14+
end
15+
16+
it 'has max_attempts of 1' do
17+
expect(job.max_attempts).to eq(1)
18+
end
19+
20+
describe '#perform' do
21+
context 'when the lifecycle_type column is missing on every table' do
22+
let!(:app) { AppModel.make }
23+
let!(:droplet) { DropletModel.make }
24+
let!(:build) { BuildModel.make }
25+
26+
before do
27+
db[:apps].where(guid: app.guid).update(lifecycle_type: nil)
28+
db[:droplets].where(guid: droplet.guid).update(lifecycle_type: nil)
29+
db[:builds].where(guid: build.guid).update(lifecycle_type: nil)
30+
allow(db).to receive(:schema).and_call_original
31+
%i[apps droplets builds].each do |table|
32+
allow(db).to receive(:schema).with(table, reload: true).and_return(
33+
[[:guid, {}], [:name, {}]]
34+
)
35+
end
36+
end
37+
38+
it 'does not issue any UPDATE statements' do
39+
expect { job.perform }.to have_queried_db_times(/update .(apps|droplets|builds). set/i, 0)
40+
end
41+
42+
it 'leaves NULL rows untouched' do
43+
job.perform
44+
expect(db[:apps].where(guid: app.guid).get(:lifecycle_type)).to be_nil
45+
expect(db[:droplets].where(guid: droplet.guid).get(:lifecycle_type)).to be_nil
46+
expect(db[:builds].where(guid: build.guid).get(:lifecycle_type)).to be_nil
47+
end
48+
end
49+
50+
context 'when no rows have NULL lifecycle_type on any table' do
51+
before do
52+
AppModel.make
53+
DropletModel.make
54+
BuildModel.make
55+
end
56+
57+
it 'does not issue any UPDATE statements' do
58+
expect { job.perform }.to have_queried_db_times(/update .(apps|droplets|builds). set/i, 0)
59+
end
60+
end
61+
62+
context 'when there are apps with NULL lifecycle_type' do
63+
let(:buildpack_app) { AppModel.make }
64+
let(:cnb_app) { AppModel.make(:cnb) }
65+
let(:docker_app) { AppModel.make(:docker) }
66+
67+
before do
68+
db[:apps].where(guid: [buildpack_app.guid, cnb_app.guid, docker_app.guid]).update(lifecycle_type: nil)
69+
end
70+
71+
it 'sets lifecycle_type accordingly' do
72+
job.perform
73+
expect(db[:apps].where(guid: buildpack_app.guid).get(:lifecycle_type)).to eq(BuildpackLifecycleDataModel::LIFECYCLE_TYPE)
74+
expect(db[:apps].where(guid: cnb_app.guid).get(:lifecycle_type)).to eq(CNBLifecycleDataModel::LIFECYCLE_TYPE)
75+
expect(db[:apps].where(guid: docker_app.guid).get(:lifecycle_type)).to eq(DockerLifecycleDataModel::LIFECYCLE_TYPE)
76+
end
77+
78+
it 'does not touch updated_at' do
79+
original_updated_at = db[:apps].where(guid: [buildpack_app.guid, cnb_app.guid, docker_app.guid]).select_map(%i[guid updated_at]).to_h
80+
job.perform
81+
expect(db[:apps].where(guid: [buildpack_app.guid, cnb_app.guid, docker_app.guid]).select_map(%i[guid updated_at]).to_h).to eq(original_updated_at)
82+
end
83+
end
84+
85+
context 'when there are droplets with NULL lifecycle_type' do
86+
let(:buildpack_droplet) { DropletModel.make }
87+
let(:cnb_droplet) { DropletModel.make(:cnb) }
88+
let(:docker_droplet) { DropletModel.make(:docker) }
89+
90+
before do
91+
db[:droplets].where(guid: [buildpack_droplet.guid, cnb_droplet.guid, docker_droplet.guid]).update(lifecycle_type: nil)
92+
end
93+
94+
it 'sets lifecycle_type accordingly' do
95+
job.perform
96+
expect(db[:droplets].where(guid: buildpack_droplet.guid).get(:lifecycle_type)).to eq(BuildpackLifecycleDataModel::LIFECYCLE_TYPE)
97+
expect(db[:droplets].where(guid: cnb_droplet.guid).get(:lifecycle_type)).to eq(CNBLifecycleDataModel::LIFECYCLE_TYPE)
98+
expect(db[:droplets].where(guid: docker_droplet.guid).get(:lifecycle_type)).to eq(DockerLifecycleDataModel::LIFECYCLE_TYPE)
99+
end
100+
101+
it 'does not touch updated_at' do
102+
original_updated_at = db[:droplets].where(guid: [buildpack_droplet.guid, cnb_droplet.guid, docker_droplet.guid]).select_map(%i[guid updated_at]).to_h
103+
job.perform
104+
expect(db[:droplets].where(guid: [buildpack_droplet.guid, cnb_droplet.guid, docker_droplet.guid]).select_map(%i[guid updated_at]).to_h).to eq(original_updated_at)
105+
end
106+
end
107+
108+
context 'when there are builds with NULL lifecycle_type' do
109+
let(:buildpack_build) { BuildModel.make }
110+
let(:cnb_build) { BuildModel.make(:cnb) }
111+
let(:docker_build) { BuildModel.make(:docker) }
112+
113+
before do
114+
db[:builds].where(guid: [buildpack_build.guid, cnb_build.guid, docker_build.guid]).update(lifecycle_type: nil)
115+
end
116+
117+
it 'sets lifecycle_type accordingly' do
118+
job.perform
119+
expect(db[:builds].where(guid: buildpack_build.guid).get(:lifecycle_type)).to eq(BuildpackLifecycleDataModel::LIFECYCLE_TYPE)
120+
expect(db[:builds].where(guid: cnb_build.guid).get(:lifecycle_type)).to eq(CNBLifecycleDataModel::LIFECYCLE_TYPE)
121+
expect(db[:builds].where(guid: docker_build.guid).get(:lifecycle_type)).to eq(DockerLifecycleDataModel::LIFECYCLE_TYPE)
122+
end
123+
124+
it 'does not touch updated_at' do
125+
original_updated_at = db[:builds].where(guid: [buildpack_build.guid, cnb_build.guid, docker_build.guid]).select_map(%i[guid updated_at]).to_h
126+
job.perform
127+
expect(db[:builds].where(guid: [buildpack_build.guid, cnb_build.guid, docker_build.guid]).select_map(%i[guid updated_at]).to_h).to eq(original_updated_at)
128+
end
129+
end
130+
131+
context 'with more rows than batch_size * batches_per_run' do
132+
subject(:job) { LifecycleTypeBackfill.new(batch_size: 2, batches_per_run: 2) }
133+
134+
before do
135+
5.times { AppModel.make }
136+
db[:apps].update(lifecycle_type: nil)
137+
end
138+
139+
it 'updates at most batch_size * batches_per_run rows in a single perform' do
140+
expect { job.perform }.to change { db[:apps].where(lifecycle_type: nil).count }.from(5).to(1)
141+
end
142+
143+
it 'processes the remainder on the next perform' do
144+
job.perform
145+
job.perform
146+
expect(db[:apps].where(lifecycle_type: nil).count).to eq(0)
147+
end
148+
end
149+
150+
context 'with fewer rows than batch_size' do
151+
subject(:job) { LifecycleTypeBackfill.new(batch_size: 2, batches_per_run: 2) }
152+
153+
before do
154+
AppModel.make
155+
db[:apps].update(lifecycle_type: nil)
156+
end
157+
158+
it 'updates every NULL row in a single perform' do
159+
job.perform
160+
expect(db[:apps].where(lifecycle_type: nil).count).to eq(0)
161+
end
162+
163+
it 'issues exactly one SELECT for guids, subsequent batch is skipped' do
164+
expect { job.perform }.to have_queried_db_times(/select .guid. from .apps. where \(.lifecycle_type. is null\)/i, 1)
165+
end
166+
end
167+
168+
context 'when batches_per_run is -1 (drain mode)' do
169+
subject(:job) { LifecycleTypeBackfill.new(batch_size: 2, batches_per_run: -1) }
170+
171+
before do
172+
5.times { AppModel.make }
173+
db[:apps].update(lifecycle_type: nil)
174+
end
175+
176+
it 'keeps batching until no NULL rows remain' do
177+
job.perform
178+
expect(db[:apps].where(lifecycle_type: nil).count).to eq(0)
179+
end
180+
end
181+
end
182+
end
183+
end
184+
end

spec/unit/lib/cloud_controller/clock/scheduler_spec.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ module VCAP::CloudController
2222
pollable_jobs: { cutoff_age_in_days: 2 },
2323
service_operations_initial_cleanup: { frequency_in_seconds: 600 },
2424
service_operations_create_in_progress_cleanup: { frequency_in_seconds: 600 },
25+
lifecycle_type_backfill: { frequency_in_seconds: 500 },
2526
service_usage_events: { cutoff_age_in_days: 5 },
2627
completed_tasks: { cutoff_age_in_days: 6 },
2728
pending_droplets: { frequency_in_seconds: 300, expiration_in_seconds: 600 },
@@ -168,6 +169,12 @@ module VCAP::CloudController
168169
expect(block.call).to be_instance_of(Jobs::Runtime::ServiceOperationsCreateInProgressCleanup)
169170
end
170171

172+
expect(clock).to receive(:schedule_frequent_worker_job) do |args, &block|
173+
expect(args).to eql(name: 'lifecycle_type_backfill', interval: 500)
174+
expect(Jobs::Runtime::LifecycleTypeBackfill).to receive(:new).and_call_original
175+
expect(block.call).to be_instance_of(Jobs::Runtime::LifecycleTypeBackfill)
176+
end
177+
171178
schedule.start
172179
end
173180

0 commit comments

Comments
 (0)