Skip to content

Commit 49c37e2

Browse files
authored
Bigint Migration for 'events' Table (Step 3) (#4406)
- implementation of steps 3a and 3b for the events table - step 3a: add check constraint (NOT VALID) / run VALIDATE CONSTRAINT separately with longer timeout and less restrictive locks - step 3b: drop check constraint and trigger function / drop old id column / switch id_bigint -> id (index added concurrently) - down migration fully implemented for testing purposes - added comments to step 1 and step 3b down migrations - they should not be used in a production system
1 parent 1cf84a0 commit 49c37e2

9 files changed

+573
-7
lines changed

db/migrations/20250327142351_bigint_migration_events_step1.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@
1414

1515
down do
1616
if database_type == :postgres
17+
# There is no guarantee that the table is still empty - which was the condition for simply switching the id
18+
# column's type to bigint. We nevertheless want to revert the type to integer as this is the opposite procedure of
19+
# the up migration. In case there is a lot of data in the table at this moment in time, this change might be
20+
# problematic, e.g. take a longer time.
21+
#
22+
# Ideally this down migration SHOULD NEVER BE EXECUTED IN A PRODUCTION SYSTEM! (It's there for proper integration
23+
# testing of the bigint migration steps.)
1724
VCAP::BigintMigration.revert_pk_to_integer(self, :events)
25+
1826
VCAP::BigintMigration.drop_trigger_function(self, :events)
1927
VCAP::BigintMigration.drop_bigint_column(self, :events)
2028
end
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
require 'database/bigint_migration'
2+
3+
Sequel.migration do
4+
no_transaction
5+
6+
up do
7+
if database_type == :postgres && !VCAP::BigintMigration.migration_completed?(self, :events) && !VCAP::BigintMigration.migration_skipped?(self, :events)
8+
transaction do
9+
VCAP::BigintMigration.add_check_constraint(self, :events)
10+
end
11+
12+
begin
13+
VCAP::Migration.with_concurrent_timeout(self) do
14+
VCAP::BigintMigration.validate_check_constraint(self, :events)
15+
end
16+
rescue Sequel::CheckConstraintViolation
17+
raise "Failed to add check constraint on 'events' table!\n" \
18+
"There are rows where 'id_bigint' does not match 'id', thus step 3 of the bigint migration cannot be executed.\n" \
19+
"Consider running rake task 'db:bigint_backfill[events]'."
20+
end
21+
end
22+
end
23+
24+
down do
25+
transaction do
26+
VCAP::BigintMigration.drop_check_constraint(self, :events) if database_type == :postgres
27+
end
28+
end
29+
end
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
require 'database/bigint_migration'
2+
3+
Sequel.migration do
4+
no_transaction
5+
6+
up do
7+
if database_type == :postgres && VCAP::BigintMigration.has_check_constraint?(self, :events)
8+
transaction do
9+
# Drop check constraint and trigger function
10+
VCAP::BigintMigration.drop_check_constraint(self, :events)
11+
VCAP::BigintMigration.drop_trigger_function(self, :events)
12+
13+
# Drop old id column
14+
VCAP::BigintMigration.drop_pk_column(self, :events)
15+
16+
# Switch id_bigint -> id
17+
VCAP::BigintMigration.rename_bigint_column(self, :events)
18+
VCAP::BigintMigration.add_pk_constraint(self, :events)
19+
VCAP::BigintMigration.set_pk_as_identity_with_correct_start_value(self, :events)
20+
end
21+
22+
# The index is added concurrently.
23+
VCAP::Migration.with_concurrent_timeout(self) do
24+
VCAP::BigintMigration.add_timestamp_pk_index(self, :events)
25+
end
26+
end
27+
end
28+
29+
down do
30+
if database_type == :postgres && VCAP::BigintMigration.migration_completed?(self, :events)
31+
transaction do
32+
# Revert id -> id_bigint
33+
VCAP::BigintMigration.drop_identity(self, :events)
34+
VCAP::BigintMigration.drop_timestamp_pk_index(self, :events)
35+
VCAP::BigintMigration.drop_pk_constraint(self, :events)
36+
VCAP::BigintMigration.revert_bigint_column_name(self, :events)
37+
38+
# Restore old id column
39+
VCAP::BigintMigration.add_id_column(self, :events)
40+
41+
# To restore the previous state it is necessary to backfill the id column. In case there is a lot of data in the
42+
# table this might be problematic, e.g. take a longer time.
43+
#
44+
# Ideally this down migration SHOULD NEVER BE EXECUTED IN A PRODUCTION SYSTEM! (It's there for proper integration
45+
# testing of the bigint migration steps.)
46+
VCAP::BigintMigration.backfill_id(self, :events)
47+
48+
VCAP::BigintMigration.add_pk_constraint(self, :events)
49+
VCAP::BigintMigration.set_pk_as_identity_with_correct_start_value(self, :events)
50+
51+
# Recreate trigger function and check constraint
52+
VCAP::BigintMigration.create_trigger_function(self, :events)
53+
VCAP::BigintMigration.add_check_constraint(self, :events)
54+
end
55+
56+
# The index is re-added concurrently.
57+
VCAP::Migration.with_concurrent_timeout(self) do
58+
VCAP::BigintMigration.add_timestamp_pk_index(self, :events)
59+
end
60+
end
61+
end
62+
end

lib/database/bigint_migration.rb

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,22 +50,128 @@ def backfill(logger, db, table, batch_size: 10_000, iterations: -1)
5050

5151
logger.info("starting bigint backfill on table '#{table}' (batch_size: #{batch_size}, iterations: #{iterations})")
5252
loop do
53-
updated_rows = db.
54-
from(table, :batch).
55-
with(:batch, db[table].select(:id).where(id_bigint: nil).order(:id).limit(batch_size).for_update.skip_locked).
56-
where(Sequel.qualify(table, :id) => :batch__id).
57-
update(id_bigint: :batch__id)
53+
updated_rows = backfill_batch(db, table, :id, :id_bigint, batch_size)
5854
logger.info("updated #{updated_rows} rows")
5955
iterations -= 1 if iterations > 0
6056
break if updated_rows < batch_size || iterations == 0
6157
end
6258
logger.info("finished bigint backfill on table '#{table}'")
6359
end
6460

61+
def migration_completed?(db, table)
62+
column_type(db, table, :id) == 'bigint'
63+
end
64+
65+
def migration_skipped?(db, table)
66+
!column_exists?(db, table, :id_bigint)
67+
end
68+
69+
def add_check_constraint(db, table)
70+
return if has_check_constraint?(db, table)
71+
72+
constraint_name = check_constraint_name(table)
73+
db.alter_table(table) do
74+
add_constraint(name: constraint_name, not_valid: true) do
75+
Sequel.lit('id_bigint IS NOT NULL AND id_bigint = id')
76+
end
77+
end
78+
end
79+
80+
def drop_check_constraint(db, table)
81+
return unless has_check_constraint?(db, table)
82+
83+
constraint_name = check_constraint_name(table)
84+
db.alter_table(table) do
85+
drop_constraint(constraint_name)
86+
end
87+
end
88+
89+
def validate_check_constraint(db, table)
90+
db.run("ALTER TABLE #{table} VALIDATE CONSTRAINT #{check_constraint_name(table)}")
91+
end
92+
93+
def has_check_constraint?(db, table)
94+
check_constraint_exists?(db, table, check_constraint_name(table))
95+
end
96+
97+
def drop_pk_column(db, table)
98+
db.drop_column(table, :id, if_exists: true)
99+
end
100+
101+
def add_id_column(db, table)
102+
db.add_column(table, :id, :integer, if_not_exists: true)
103+
end
104+
105+
def rename_bigint_column(db, table)
106+
db.rename_column(table, :id_bigint, :id) if column_exists?(db, table, :id_bigint) && !column_exists?(db, table, :id)
107+
end
108+
109+
def revert_bigint_column_name(db, table)
110+
db.rename_column(table, :id, :id_bigint) if column_exists?(db, table, :id) && column_type(db, table, :id) == 'bigint' && !column_exists?(db, table, :id_bigint)
111+
end
112+
113+
def add_pk_constraint(db, table)
114+
return if db.primary_key(table) == 'id'
115+
116+
db.alter_table(table) do
117+
add_primary_key([:id])
118+
end
119+
end
120+
121+
def drop_pk_constraint(db, table)
122+
return unless db.primary_key(table) == 'id'
123+
124+
constraint_name = pk_constraint_name(table)
125+
db.alter_table(table) do
126+
drop_constraint(constraint_name)
127+
set_column_allow_null(:id, true)
128+
end
129+
end
130+
131+
def add_timestamp_pk_index(db, table)
132+
db.add_index(table, %i[timestamp id], name: timestamp_id_index_name(table), unique: false, if_not_exists: true, concurrently: true)
133+
end
134+
135+
def drop_timestamp_pk_index(db, table)
136+
db.drop_index(table, %i[timestamp id], name: timestamp_id_index_name(table), if_exists: true)
137+
end
138+
139+
def set_pk_as_identity_with_correct_start_value(db, table)
140+
return if column_attribute(db, table, :id, :auto_increment) == true
141+
142+
block = <<~BLOCK
143+
DO $$
144+
DECLARE
145+
max_id BIGINT;
146+
BEGIN
147+
SELECT COALESCE(MAX(id), 0) + 1 INTO max_id FROM #{table};
148+
149+
EXECUTE format('ALTER TABLE #{table} ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (START WITH %s)', max_id);
150+
END $$;
151+
BLOCK
152+
db.run(block)
153+
end
154+
155+
def drop_identity(db, table)
156+
db.run("ALTER TABLE #{table} ALTER COLUMN id DROP IDENTITY IF EXISTS")
157+
end
158+
159+
def backfill_id(db, table)
160+
batch_size = 10_000
161+
loop do
162+
updated_rows = backfill_batch(db, table, :id_bigint, :id, batch_size)
163+
break if updated_rows < batch_size
164+
end
165+
end
166+
65167
private
66168

169+
def column_attribute(db, table, column, attribute)
170+
db.schema(table).find { |col, _| col == column }&.dig(1, attribute)
171+
end
172+
67173
def column_type(db, table, column)
68-
db.schema(table).find { |col, _| col == column }&.dig(1, :db_type)
174+
column_attribute(db, table, column, :db_type)
69175
end
70176

71177
def function_name(table)
@@ -79,5 +185,29 @@ def trigger_name(table)
79185
def column_exists?(db, table, column)
80186
db[table].columns.include?(column)
81187
end
188+
189+
def check_constraint_name(table)
190+
:"#{table}_check_id_bigint_matches_id"
191+
end
192+
193+
def check_constraint_exists?(db, table, constraint_name)
194+
db.check_constraints(table).include?(constraint_name)
195+
end
196+
197+
def pk_constraint_name(table)
198+
:"#{table}_pkey"
199+
end
200+
201+
def timestamp_id_index_name(table)
202+
:"#{table}_timestamp_id_index"
203+
end
204+
205+
def backfill_batch(db, table, from_column, to_column, batch_size)
206+
db.
207+
from(table, :batch).
208+
with(:batch, db[table].select(from_column).where(to_column => nil).order(from_column).limit(batch_size).for_update.skip_locked).
209+
where(Sequel.qualify(table, from_column) => :"batch__#{from_column}").
210+
update(to_column => :"batch__#{from_column}")
211+
end
82212
end
83213
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
require 'spec_helper'
2+
require 'migrations/helpers/bigint_migration_step3_shared_context'
3+
4+
RSpec.describe 'bigint migration - events table - step3a', isolation: :truncation, type: :migration do
5+
include_context 'bigint migration step3a' do
6+
let(:migration_filename_step1) { '20250327142351_bigint_migration_events_step1.rb' }
7+
let(:migration_filename_step3a) { '20250603103400_bigint_migration_events_step3a.rb' }
8+
let(:table) { :events }
9+
let(:insert) do
10+
lambda do |db|
11+
db[:events].insert(guid: SecureRandom.uuid, timestamp: Time.now.utc, type: 'type',
12+
actor: 'actor', actor_type: 'actor_type',
13+
actee: 'actee', actee_type: 'actee_type')
14+
end
15+
end
16+
end
17+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
require 'spec_helper'
2+
require 'migrations/helpers/bigint_migration_step3_shared_context'
3+
4+
RSpec.describe 'bigint migration - events table - step3b', isolation: :truncation, type: :migration do
5+
include_context 'bigint migration step3b' do
6+
let(:migration_filename_step1) { '20250327142351_bigint_migration_events_step1.rb' }
7+
let(:migration_filename_step3a) { '20250603103400_bigint_migration_events_step3a.rb' }
8+
let(:migration_filename_step3b) { '20250603103500_bigint_migration_events_step3b.rb' }
9+
let(:table) { :events }
10+
let(:insert) do
11+
lambda do |db|
12+
db[:events].insert(guid: SecureRandom.uuid, timestamp: Time.now.utc, type: 'type',
13+
actor: 'actor', actor_type: 'actor_type',
14+
actee: 'actee', actee_type: 'actee_type')
15+
end
16+
end
17+
end
18+
end

spec/migrations/helpers/bigint_migration_step1_shared_context.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
skip unless db.database_type == :postgres
1313

1414
allow_any_instance_of(VCAP::CloudController::Config).to receive(:get).with(:skip_bigint_id_migration).and_return(skip_bigint_id_migration)
15+
allow_any_instance_of(VCAP::CloudController::Config).to receive(:get).with(:migration_psql_concurrent_statement_timeout_in_seconds).and_return(300)
1516
end
1617

1718
describe 'up' do
@@ -60,6 +61,10 @@
6061
context 'when the table is not empty' do
6162
let!(:old_id) { insert.call(db) }
6263

64+
after do
65+
db[table].delete # Necessary to successfully run subsequent migrations in the after block of the migration shared context...
66+
end
67+
6368
it "does not change the id column's type" do
6469
expect(db).to have_table_with_column_and_type(table, :id, 'integer')
6570

@@ -186,6 +191,10 @@
186191
run_migration
187192
end
188193

194+
after do
195+
db[table].delete # Necessary to successfully run subsequent migrations in the after block of the migration shared context...
196+
end
197+
189198
it 'drops the id_bigint column' do
190199
expect(db).to have_table_with_column(table, :id_bigint)
191200

0 commit comments

Comments
 (0)