pg_online_schema_change 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +99 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +18 -0
- data/Gemfile.lock +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/Rakefile +12 -0
- data/bin/console +8 -0
- data/bin/pg-online-schema-change +6 -0
- data/bin/setup +8 -0
- data/diagrams/how-it-works.excalidraw +1401 -0
- data/diagrams/how-it-works.png +0 -0
- data/docker-compose.yml +9 -0
- data/lib/pg_online_schema_change/cli.rb +44 -0
- data/lib/pg_online_schema_change/client.rb +49 -0
- data/lib/pg_online_schema_change/functions.rb +62 -0
- data/lib/pg_online_schema_change/helper.rb +21 -0
- data/lib/pg_online_schema_change/orchestrate.rb +263 -0
- data/lib/pg_online_schema_change/query.rb +302 -0
- data/lib/pg_online_schema_change/replay.rb +122 -0
- data/lib/pg_online_schema_change/store.rb +18 -0
- data/lib/pg_online_schema_change/version.rb +5 -0
- data/lib/pg_online_schema_change.rb +33 -0
- metadata +76 -0
Binary file
|
data/docker-compose.yml
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require "thor"
|
2
|
+
|
3
|
+
module PgOnlineSchemaChange
|
4
|
+
class CLI < Thor
|
5
|
+
desc "perform", "Perform the set of operations to safely apply the schema change with minimal locks"
|
6
|
+
method_option :alter_statement, aliases: "-a", type: :string, required: true,
|
7
|
+
desc: "The ALTER statement to perform the schema change"
|
8
|
+
method_option :schema, aliases: "-s", type: :string, required: true, default: "public",
|
9
|
+
desc: "The schema in which the table is"
|
10
|
+
method_option :dbname, aliases: "-d", type: :string, required: true, desc: "Name of the database"
|
11
|
+
method_option :host, aliases: "-h", type: :string, required: true, desc: "Server host where the Database is located"
|
12
|
+
method_option :username, aliases: "-u", type: :string, required: true, desc: "Username for the Database"
|
13
|
+
method_option :port, aliases: "-p", type: :numeric, required: true, default: 5432, desc: "Port for the Database"
|
14
|
+
method_option :password, aliases: "-w", type: :string, required: true, desc: "Password for the Database"
|
15
|
+
method_option :verbose, aliases: "-v", type: :boolean, default: false, desc: "Emit logs in debug mode"
|
16
|
+
method_option :drop, aliases: "-f", type: :boolean, default: false,
|
17
|
+
desc: "Drop the original table in the end after the swap"
|
18
|
+
method_option :kill_backends, aliases: "-k", type: :boolean, default: false,
|
19
|
+
desc: "Kill other competing queries/backends when trying to acquire lock for the shadow table creation and swap. It will wait for --wait-time-for-lock duration before killing backends and try upto 3 times."
|
20
|
+
method_option :wait_time_for_lock, aliases: "-w", type: :numeric, default: 10,
|
21
|
+
desc: "Time to wait before killing backends to acquire lock and/or retrying upto 3 times. It will kill backends if --kill-backends is true, otherwise try upto 3 times and exit if it cannot acquire a lock."
|
22
|
+
method_option :copy_statement, aliases: "-c", type: :string, required: false, default: "",
|
23
|
+
desc: "Takes a .sql file location where you can provide a custom query to be played (ex: backfills) when pgosc copies data from the primary to the shadow table. More examples in README."
|
24
|
+
|
25
|
+
def perform
|
26
|
+
client_options = Struct.new(*options.keys.map(&:to_sym)).new(*options.values)
|
27
|
+
|
28
|
+
PgOnlineSchemaChange.logger = client_options.verbose
|
29
|
+
PgOnlineSchemaChange::Orchestrate.run!(client_options)
|
30
|
+
end
|
31
|
+
|
32
|
+
desc "--version, -v", "print the version"
|
33
|
+
|
34
|
+
def version
|
35
|
+
puts PgOnlineSchemaChange::VERSION
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.exit_on_failure?
|
39
|
+
true
|
40
|
+
end
|
41
|
+
|
42
|
+
default_task :perform
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require "pg"
|
2
|
+
|
3
|
+
module PgOnlineSchemaChange
|
4
|
+
class Client
|
5
|
+
attr_accessor :alter_statement, :schema, :dbname, :host, :username, :port, :password, :connection, :table, :drop,
|
6
|
+
:kill_backends, :wait_time_for_lock, :copy_statement
|
7
|
+
|
8
|
+
def initialize(options)
|
9
|
+
@alter_statement = options.alter_statement
|
10
|
+
@schema = options.schema
|
11
|
+
@dbname = options.dbname
|
12
|
+
@host = options.host
|
13
|
+
@username = options.username
|
14
|
+
@port = options.port
|
15
|
+
@password = options.password
|
16
|
+
@drop = options.drop
|
17
|
+
@kill_backends = options.kill_backends
|
18
|
+
@wait_time_for_lock = options.wait_time_for_lock
|
19
|
+
handle_copy_statement(options.copy_statement)
|
20
|
+
|
21
|
+
@connection = PG.connect(
|
22
|
+
dbname: @dbname,
|
23
|
+
host: @host,
|
24
|
+
user: @username,
|
25
|
+
password: @password,
|
26
|
+
port: @port,
|
27
|
+
)
|
28
|
+
|
29
|
+
raise Error, "Not a valid ALTER statement: #{@alter_statement}" unless Query.alter_statement?(@alter_statement)
|
30
|
+
|
31
|
+
unless Query.same_table?(@alter_statement)
|
32
|
+
raise Error "All statements should belong to the same table: #{@alter_statement}"
|
33
|
+
end
|
34
|
+
|
35
|
+
@table = Query.table(@alter_statement)
|
36
|
+
|
37
|
+
PgOnlineSchemaChange.logger.debug("Connection established")
|
38
|
+
end
|
39
|
+
|
40
|
+
def handle_copy_statement(statement)
|
41
|
+
return if statement.nil? || statement == ""
|
42
|
+
|
43
|
+
file_path = File.expand_path(statement)
|
44
|
+
raise Error, "File not found: #{file_path}" unless File.file?(file_path)
|
45
|
+
|
46
|
+
@copy_statement = File.open(file_path, "rb", &:read)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
FUNC_FIX_SERIAL_SEQUENCE = <<~SQL.freeze
|
2
|
+
CREATE OR REPLACE FUNCTION fix_serial_sequence(_table regclass, _newtable text)
|
3
|
+
RETURNS void AS
|
4
|
+
$func$
|
5
|
+
DECLARE
|
6
|
+
_sql text;
|
7
|
+
BEGIN
|
8
|
+
|
9
|
+
-- Update serial columns to ensure copied table doesn't follow same sequence as primary table
|
10
|
+
SELECT INTO _sql
|
11
|
+
string_agg('CREATE SEQUENCE ' || seq, E';\n') || E';\n'
|
12
|
+
|| string_agg(format('ALTER SEQUENCE %s OWNED BY %I.%I'
|
13
|
+
, seq, _newtable, a.attname), E';\n') || E';\n'
|
14
|
+
|| 'ALTER TABLE ' || quote_ident(_newtable) || E'\n '
|
15
|
+
|| string_agg(format($$ALTER %I SET DEFAULT nextval('%s'::regclass)$$
|
16
|
+
, a.attname, seq), E'\n, ')
|
17
|
+
FROM pg_attribute a
|
18
|
+
JOIN pg_attrdef ad ON ad.adrelid = a.attrelid
|
19
|
+
AND ad.adnum = a.attnum
|
20
|
+
, quote_ident(_newtable || '_' || a.attname || '_seq') AS seq
|
21
|
+
WHERE a.attrelid = _table
|
22
|
+
AND a.attnum > 0
|
23
|
+
AND NOT a.attisdropped
|
24
|
+
AND a.atttypid = ANY ('{int,int8,int2}'::regtype[])
|
25
|
+
AND pg_get_expr(ad.adbin, ad.adrelid) = 'nextval('''
|
26
|
+
|| (pg_get_serial_sequence (a.attrelid::regclass::text, a.attname))::regclass
|
27
|
+
|| '''::regclass)'
|
28
|
+
;
|
29
|
+
|
30
|
+
IF _sql IS NOT NULL THEN
|
31
|
+
EXECUTE _sql;
|
32
|
+
END IF;
|
33
|
+
|
34
|
+
END
|
35
|
+
$func$ LANGUAGE plpgsql VOLATILE;
|
36
|
+
SQL
|
37
|
+
|
38
|
+
FUNC_CREATE_TABLE_ALL = <<~SQL.freeze
|
39
|
+
CREATE OR REPLACE FUNCTION create_table_all(source_table text, newsource_table text)
|
40
|
+
RETURNS void language plpgsql
|
41
|
+
as $$
|
42
|
+
declare
|
43
|
+
rec record;
|
44
|
+
begin
|
45
|
+
EXECUTE format(
|
46
|
+
'CREATE TABLE %s (LIKE %s including all)',
|
47
|
+
newsource_table, source_table);
|
48
|
+
for rec in
|
49
|
+
SELECT oid, conname
|
50
|
+
FROM pg_constraint
|
51
|
+
WHERE contype = 'f'
|
52
|
+
AND conrelid = source_table::regclass
|
53
|
+
LOOP
|
54
|
+
EXECUTE format(
|
55
|
+
'ALTER TABLE %s add constraint %s %s',
|
56
|
+
newsource_table,
|
57
|
+
rec.conname,
|
58
|
+
pg_get_constraintdef(rec.oid));
|
59
|
+
END LOOP;
|
60
|
+
END
|
61
|
+
$$;
|
62
|
+
SQL
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module PgOnlineSchemaChange
|
2
|
+
module Helper
|
3
|
+
def primary_key
|
4
|
+
result = Store.get(:primary_key)
|
5
|
+
return result if result
|
6
|
+
|
7
|
+
Store.set(:primary_key, Query.primary_key_for(client, client.table))
|
8
|
+
end
|
9
|
+
|
10
|
+
def logger
|
11
|
+
PgOnlineSchemaChange.logger
|
12
|
+
end
|
13
|
+
|
14
|
+
def method_missing(method, *_args)
|
15
|
+
result = Store.send(:get, method)
|
16
|
+
return result if result
|
17
|
+
|
18
|
+
raise ArgumentError, "Method `#{method}` doesn't exist."
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,263 @@
|
|
1
|
+
require "securerandom"
|
2
|
+
|
3
|
+
module PgOnlineSchemaChange
|
4
|
+
class Orchestrate
|
5
|
+
SWAP_STATEMENT_TIMEOUT = "5s".freeze
|
6
|
+
|
7
|
+
extend Helper
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def setup!(options)
|
11
|
+
client = Store.set(:client, Client.new(options))
|
12
|
+
|
13
|
+
sql = <<~SQL
|
14
|
+
SET statement_timeout = 0;
|
15
|
+
SET client_min_messages = warning;
|
16
|
+
SET search_path TO #{client.schema};
|
17
|
+
SQL
|
18
|
+
|
19
|
+
Query.run(client.connection, sql)
|
20
|
+
# install functions
|
21
|
+
Query.run(client.connection, FUNC_FIX_SERIAL_SEQUENCE)
|
22
|
+
Query.run(client.connection, FUNC_CREATE_TABLE_ALL)
|
23
|
+
|
24
|
+
# Set this early on to ensure their creation and cleanup (unexpected)
|
25
|
+
# happens at all times. IOW, the calls from Store.get always return
|
26
|
+
# the same value.
|
27
|
+
Store.set(:old_primary_table, "pgosc_op_table_#{client.table}")
|
28
|
+
Store.set(:audit_table, "pgosc_at_#{client.table}_#{random_string}")
|
29
|
+
Store.set(:shadow_table, "pgosc_st_#{client.table}_#{random_string}")
|
30
|
+
end
|
31
|
+
|
32
|
+
def run!(options)
|
33
|
+
setup!(options)
|
34
|
+
Thread.new { handle_signals! }
|
35
|
+
|
36
|
+
raise Error, "Parent table has no primary key, exiting..." if primary_key.nil?
|
37
|
+
|
38
|
+
setup_audit_table!
|
39
|
+
setup_trigger!
|
40
|
+
setup_shadow_table!
|
41
|
+
disable_vacuum!
|
42
|
+
run_alter_statement!
|
43
|
+
copy_data!
|
44
|
+
run_analyze!
|
45
|
+
replay_and_swap!
|
46
|
+
run_analyze!
|
47
|
+
validate_constraints!
|
48
|
+
drop_and_cleanup!
|
49
|
+
|
50
|
+
logger.info("All tasks successfully completed")
|
51
|
+
rescue StandardError => e
|
52
|
+
logger.fatal("Something went wrong: #{e.message}", { e: e })
|
53
|
+
|
54
|
+
drop_and_cleanup!
|
55
|
+
|
56
|
+
raise e
|
57
|
+
end
|
58
|
+
|
59
|
+
def setup_signals!
|
60
|
+
reader, writer = IO.pipe
|
61
|
+
|
62
|
+
%w[TERM QUIT INT].each do |sig|
|
63
|
+
trap(sig) { writer.puts sig }
|
64
|
+
end
|
65
|
+
|
66
|
+
reader
|
67
|
+
end
|
68
|
+
|
69
|
+
def handle_signals!
|
70
|
+
reader = setup_signals!
|
71
|
+
signal = reader.gets.chomp
|
72
|
+
|
73
|
+
while !reader.closed? && IO.select([reader])
|
74
|
+
logger.info "Signal #{signal} received, cleaning up"
|
75
|
+
|
76
|
+
client.connection.cancel
|
77
|
+
drop_and_cleanup!
|
78
|
+
reader.close
|
79
|
+
|
80
|
+
exit Signal.list[signal]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def setup_audit_table!
|
85
|
+
logger.info("Setting up audit table", { audit_table: audit_table })
|
86
|
+
|
87
|
+
sql = <<~SQL
|
88
|
+
CREATE TABLE #{audit_table} (operation_type text, trigger_time timestamp, LIKE #{client.table});
|
89
|
+
SQL
|
90
|
+
|
91
|
+
Query.run(client.connection, sql)
|
92
|
+
end
|
93
|
+
|
94
|
+
def setup_trigger!
|
95
|
+
# acquire access exclusive lock to ensure audit triggers
|
96
|
+
# are setup fine. This also calls kill_backends (if opted in via flag)
|
97
|
+
# so any competing backends will be killed to setup the trigger
|
98
|
+
opened = Query.open_lock_exclusive(client, client.table)
|
99
|
+
|
100
|
+
raise AccessExclusiveLockNotAcquired unless opened
|
101
|
+
|
102
|
+
logger.info("Setting up triggers")
|
103
|
+
|
104
|
+
sql = <<~SQL
|
105
|
+
DROP TRIGGER IF EXISTS primary_to_audit_table_trigger ON #{client.table};
|
106
|
+
|
107
|
+
CREATE OR REPLACE FUNCTION primary_to_audit_table_trigger()
|
108
|
+
RETURNS TRIGGER AS
|
109
|
+
$$
|
110
|
+
BEGIN
|
111
|
+
IF ( TG_OP = 'INSERT') THEN
|
112
|
+
INSERT INTO \"#{audit_table}\" select 'INSERT', now(), NEW.* ;
|
113
|
+
RETURN NEW;
|
114
|
+
ELSIF ( TG_OP = 'UPDATE') THEN
|
115
|
+
INSERT INTO \"#{audit_table}\" select 'UPDATE', now(), NEW.* ;
|
116
|
+
RETURN NEW;
|
117
|
+
ELSIF ( TG_OP = 'DELETE') THEN
|
118
|
+
INSERT INTO \"#{audit_table}\" select 'DELETE', now(), OLD.* ;
|
119
|
+
RETURN NEW;
|
120
|
+
END IF;
|
121
|
+
END;
|
122
|
+
$$ LANGUAGE PLPGSQL SECURITY DEFINER;
|
123
|
+
|
124
|
+
CREATE TRIGGER primary_to_audit_table_trigger
|
125
|
+
AFTER INSERT OR UPDATE OR DELETE ON #{client.table}
|
126
|
+
FOR EACH ROW EXECUTE PROCEDURE primary_to_audit_table_trigger();
|
127
|
+
SQL
|
128
|
+
|
129
|
+
Query.run(client.connection, sql, opened)
|
130
|
+
ensure
|
131
|
+
Query.run(client.connection, "COMMIT;")
|
132
|
+
end
|
133
|
+
|
134
|
+
def setup_shadow_table!
|
135
|
+
logger.info("Setting up shadow table", { shadow_table: shadow_table })
|
136
|
+
|
137
|
+
Query.run(client.connection, "SELECT create_table_all('#{client.table}', '#{shadow_table}');")
|
138
|
+
|
139
|
+
# update serials
|
140
|
+
Query.run(client.connection, "SELECT fix_serial_sequence('#{client.table}', '#{shadow_table}');")
|
141
|
+
end
|
142
|
+
|
143
|
+
# Disabling vacuum to avoid any issues during the process
|
144
|
+
def disable_vacuum!
|
145
|
+
result = Query.storage_parameters_for(client, client.table) || ""
|
146
|
+
primary_table_storage_parameters = Store.set(:primary_table_storage_parameters, result)
|
147
|
+
|
148
|
+
logger.debug("Disabling vacuum on shadow and audit table",
|
149
|
+
{ shadow_table: shadow_table, audit_table: audit_table })
|
150
|
+
sql = <<~SQL
|
151
|
+
ALTER TABLE #{shadow_table} SET (
|
152
|
+
autovacuum_enabled = false, toast.autovacuum_enabled = false
|
153
|
+
);
|
154
|
+
|
155
|
+
ALTER TABLE #{audit_table} SET (
|
156
|
+
autovacuum_enabled = false, toast.autovacuum_enabled = false
|
157
|
+
);
|
158
|
+
SQL
|
159
|
+
Query.run(client.connection, sql)
|
160
|
+
end
|
161
|
+
|
162
|
+
def run_alter_statement!
|
163
|
+
statement = Query.alter_statement_for(client, shadow_table)
|
164
|
+
logger.info("Running alter statement on shadow table",
|
165
|
+
{ shadow_table: shadow_table, parent_table: client.table })
|
166
|
+
Query.run(client.connection, statement)
|
167
|
+
|
168
|
+
Store.set(:dropped_columns_list, Query.dropped_columns(client))
|
169
|
+
Store.set(:renamed_columns_list, Query.renamed_columns(client))
|
170
|
+
end
|
171
|
+
|
172
|
+
# Begin the process to copy data into copy table
|
173
|
+
# depending on the size of the table, this can be a time
|
174
|
+
# taking operation.
|
175
|
+
def copy_data!
|
176
|
+
logger.info("Copying contents..", { shadow_table: shadow_table, parent_table: client.table })
|
177
|
+
|
178
|
+
if client.copy_statement
|
179
|
+
query = format(client.copy_statement, shadow_table: shadow_table)
|
180
|
+
return Query.run(client.connection, query)
|
181
|
+
end
|
182
|
+
|
183
|
+
sql = Query.copy_data_statement(client, shadow_table)
|
184
|
+
Query.run(client.connection, sql)
|
185
|
+
end
|
186
|
+
|
187
|
+
def replay_and_swap!
|
188
|
+
Replay.begin!
|
189
|
+
rescue CountBelowDelta
|
190
|
+
logger.info("Remaining rows below delta count, proceeding towards swap")
|
191
|
+
|
192
|
+
swap!
|
193
|
+
end
|
194
|
+
|
195
|
+
def swap!
|
196
|
+
logger.info("Performing swap!")
|
197
|
+
|
198
|
+
foreign_key_statements = Query.get_foreign_keys_to_refresh(client, client.table)
|
199
|
+
storage_params_reset = primary_table_storage_parameters.empty? ? "" : "ALTER TABLE #{client.table} SET (#{primary_table_storage_parameters});"
|
200
|
+
|
201
|
+
# From here on, all statements are carried out in a single
|
202
|
+
# transaction with access exclusive lock
|
203
|
+
|
204
|
+
opened = Query.open_lock_exclusive(client, client.table)
|
205
|
+
|
206
|
+
raise AccessExclusiveLockNotAcquired unless opened
|
207
|
+
|
208
|
+
Query.run(client.connection, "SET statement_timeout to '#{SWAP_STATEMENT_TIMEOUT}';", opened)
|
209
|
+
|
210
|
+
rows = Replay.rows_to_play(opened)
|
211
|
+
Replay.play!(rows, opened)
|
212
|
+
|
213
|
+
sql = <<~SQL
|
214
|
+
ALTER TABLE #{client.table} RENAME to #{old_primary_table};
|
215
|
+
ALTER TABLE #{shadow_table} RENAME to #{client.table};
|
216
|
+
#{foreign_key_statements}
|
217
|
+
#{storage_params_reset}
|
218
|
+
DROP TRIGGER IF EXISTS primary_to_audit_table_trigger ON #{client.table};
|
219
|
+
SQL
|
220
|
+
|
221
|
+
Query.run(client.connection, sql)
|
222
|
+
ensure
|
223
|
+
Query.run(client.connection, "COMMIT;")
|
224
|
+
Query.run(client.connection, "SET statement_timeout = 0;")
|
225
|
+
end
|
226
|
+
|
227
|
+
def run_analyze!
|
228
|
+
logger.info("Performing ANALYZE!")
|
229
|
+
|
230
|
+
Query.run(client.connection, "ANALYZE VERBOSE #{client.table};")
|
231
|
+
end
|
232
|
+
|
233
|
+
def validate_constraints!
|
234
|
+
logger.info("Validating constraints!")
|
235
|
+
|
236
|
+
validate_statements = Query.get_foreign_keys_to_validate(client, client.table)
|
237
|
+
|
238
|
+
Query.run(client.connection, validate_statements)
|
239
|
+
end
|
240
|
+
|
241
|
+
def drop_and_cleanup!
|
242
|
+
primary_drop = client.drop ? "DROP TABLE IF EXISTS #{old_primary_table};" : ""
|
243
|
+
audit_table_drop = audit_table ? "DROP TABLE IF EXISTS #{audit_table}" : ""
|
244
|
+
shadow_table_drop = shadow_table ? "DROP TABLE IF EXISTS #{shadow_table}" : ""
|
245
|
+
|
246
|
+
sql = <<~SQL
|
247
|
+
#{audit_table_drop};
|
248
|
+
#{shadow_table_drop};
|
249
|
+
#{primary_drop}
|
250
|
+
RESET statement_timeout;
|
251
|
+
RESET client_min_messages;
|
252
|
+
RESET lock_timeout;
|
253
|
+
SQL
|
254
|
+
|
255
|
+
Query.run(client.connection, sql)
|
256
|
+
end
|
257
|
+
|
258
|
+
private def random_string
|
259
|
+
@random_string ||= SecureRandom.hex(3)
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|