pg_online_schema_change 0.2.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +57 -24
- data/.rubocop_todo.yml +44 -0
- data/CHANGELOG.md +15 -2
- data/Dockerfile +5 -0
- data/Gemfile +0 -13
- data/Gemfile.lock +12 -13
- data/README.md +103 -41
- data/{diagrams → docs}/how-it-works.excalidraw +500 -508
- data/docs/how-it-works.png +0 -0
- data/docs/load-test-1.png +0 -0
- data/docs/load-test.md +138 -0
- data/lib/pg_online_schema_change/cli.rb +15 -3
- data/lib/pg_online_schema_change/client.rb +15 -7
- data/lib/pg_online_schema_change/functions.rb +4 -2
- data/lib/pg_online_schema_change/helper.rb +10 -1
- data/lib/pg_online_schema_change/orchestrate.rb +25 -14
- data/lib/pg_online_schema_change/query.rb +16 -14
- data/lib/pg_online_schema_change/replay.rb +20 -14
- data/lib/pg_online_schema_change/store.rb +7 -3
- data/lib/pg_online_schema_change/version.rb +1 -1
- data/lib/pg_online_schema_change.rb +8 -12
- data/scripts/release.sh +28 -0
- metadata +182 -11
- data/diagrams/how-it-works.png +0 -0
Binary file
|
Binary file
|
data/docs/load-test.md
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
# Preliminary Load Test
|
2
|
+
|
3
|
+
## pg-osc: No downtime schema changes with 7K+ writes/s & 12k+ reads/s
|
4
|
+
|
5
|
+
This is a very basic load test performed with `pgbench` against a single instance PostgreSQL DB running on DigitialOcean with the following configuration:
|
6
|
+
|
7
|
+
- **128GB RAM**
|
8
|
+
- **32vCPU**
|
9
|
+
- **695GB Disk**
|
10
|
+
- Trasanction based connection pool with **500 pool limit**
|
11
|
+
|
12
|
+
Total time taken to run schema change: **<3mins**
|
13
|
+
|
14
|
+
## Simulating load with pgbench
|
15
|
+
|
16
|
+
**Initialize**
|
17
|
+
```
|
18
|
+
pgbench -p $PORT --initialize -s 20 -F 20 --foreign-keys --host $HOST -U $USERNAME -d $DB
|
19
|
+
```
|
20
|
+
|
21
|
+
This creates bunch of pgbench tables. The table being used with `pg-osc` is `pgbench_accounts` which has FKs and also references by other tables with FKS, containing 2M rows.
|
22
|
+
|
23
|
+
**Begin**
|
24
|
+
```
|
25
|
+
pgbench -p $PORT -j 72 -c 288 -T 500 -r --host $DB_HOST -U $USERNAME -d $DB
|
26
|
+
```
|
27
|
+
|
28
|
+
## Running pg-osc
|
29
|
+
|
30
|
+
Simple `ALTER` statement for experimentation purposes.
|
31
|
+
|
32
|
+
```sql
|
33
|
+
ALTER TABLE pgbench_accounts ADD COLUMN "purchased" BOOLEAN DEFAULT FALSE;
|
34
|
+
```
|
35
|
+
|
36
|
+
**Execution**
|
37
|
+
|
38
|
+
```bash
|
39
|
+
bundle exec bin/pg-online-schema-change perform \
|
40
|
+
-a 'ALTER TABLE pgbench_accounts ADD COLUMN "purchased" BOOLEAN DEFAULT FALSE;' \
|
41
|
+
-d "pool" \
|
42
|
+
-p 25061
|
43
|
+
-h "..." \
|
44
|
+
-u "..." \
|
45
|
+
--pull-batch-count 2000 \
|
46
|
+
--delta-count 200
|
47
|
+
```
|
48
|
+
|
49
|
+
## Outcome
|
50
|
+
|
51
|
+
**pgbench results**
|
52
|
+
|
53
|
+
```
|
54
|
+
number of transactions actually processed: 1060382
|
55
|
+
latency average = 144.874 ms
|
56
|
+
tps = 1767.057392 (including connections establishing)
|
57
|
+
tps = 1777.971823 (excluding connections establishing)
|
58
|
+
statement latencies in milliseconds:
|
59
|
+
0.479 \set aid random(1, 100000 * :scale)
|
60
|
+
0.409 \set bid random(1, 1 * :scale)
|
61
|
+
0.247 \set tid random(1, 10 * :scale)
|
62
|
+
0.208 \set delta random(-5000, 5000)
|
63
|
+
3.136 BEGIN;
|
64
|
+
4.243 UPDATE pgbench_accounts SET abalance = abalance + :delta WHERE aid = :aid;
|
65
|
+
4.488 SELECT abalance FROM pgbench_accounts WHERE aid = :aid;
|
66
|
+
71.017 UPDATE pgbench_tellers SET tbalance = tbalance + :delta WHERE tid = :tid;
|
67
|
+
46.689 UPDATE pgbench_branches SET bbalance = bbalance + :delta WHERE bid = :bid;
|
68
|
+
4.035 INSERT INTO pgbench_history (tid, bid, aid, delta, mtime) VALUES (:tid, :bid, :aid, :delta, CURRENT_TIMESTAMP);
|
69
|
+
4.166 END;
|
70
|
+
```
|
71
|
+
|
72
|
+
**Metrics**
|
73
|
+
![load-test](load-test-1.png)
|
74
|
+
|
75
|
+
**New table structure**
|
76
|
+
|
77
|
+
Added `purchased` column.
|
78
|
+
|
79
|
+
```
|
80
|
+
defaultdb=> \d+ pgbench_accounts;
|
81
|
+
Table "public.pgbench_accounts"
|
82
|
+
Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
|
83
|
+
-----------+---------------+-----------+----------+---------+----------+--------------+-------------
|
84
|
+
aid | integer | | not null | | plain | |
|
85
|
+
bid | integer | | | | plain | |
|
86
|
+
abalance | integer | | | | plain | |
|
87
|
+
filler | character(84) | | | | extended | |
|
88
|
+
purchased | boolean | | | false | plain | |
|
89
|
+
Indexes:
|
90
|
+
"pgosc_st_pgbench_accounts_815029_pkey" PRIMARY KEY, btree (aid)
|
91
|
+
Foreign-key constraints:
|
92
|
+
"pgbench_accounts_bid_fkey" FOREIGN KEY (bid) REFERENCES pgbench_branches(bid)
|
93
|
+
Referenced by:
|
94
|
+
TABLE "pgbench_history" CONSTRAINT "pgbench_history_aid_fkey" FOREIGN KEY (aid) REFERENCES pgbench_accounts(aid)
|
95
|
+
Options: autovacuum_enabled=false, fillfactor=20
|
96
|
+
```
|
97
|
+
|
98
|
+
**Logs**
|
99
|
+
|
100
|
+
<details>
|
101
|
+
<summary>Logs from pg-osc</summary>
|
102
|
+
|
103
|
+
```json
|
104
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:01.147-05:00","v":0,"msg":"Setting up audit table","audit_table":"pgosc_at_pgbench_accounts_714a8b","version":"0.4.0"}
|
105
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:01.660-05:00","v":0,"msg":"Setting up triggers","version":"0.4.0"}
|
106
|
+
NOTICE: trigger "primary_to_audit_table_trigger" for relation "pgbench_accounts" does not exist, skipping
|
107
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:01.814-05:00","v":0,"msg":"Setting up shadow table","shadow_table":"pgosc_st_pgbench_accounts_714a8b","version":"0.4.0"}
|
108
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:02.169-05:00","v":0,"msg":"Running alter statement on shadow table","shadow_table":"pgosc_st_pgbench_accounts_714a8b","parent_table":"pgbench_accounts","version":"0.4.0"}
|
109
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:02.204-05:00","v":0,"msg":"Clearing contents of audit table before copy..","shadow_table":"pgosc_st_pgbench_accounts_714a8b","parent_table":"pgbench_accounts","version":"0.4.0"}
|
110
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:02.240-05:00","v":0,"msg":"Copying contents..","shadow_table":"pgosc_st_pgbench_accounts_714a8b","parent_table":"pgbench_accounts","version":"0.4.0"}
|
111
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:20.481-05:00","v":0,"msg":"Performing ANALYZE!","version":"0.4.0"}
|
112
|
+
INFO: analyzing "public.pgbench_accounts"
|
113
|
+
INFO: "pgbench_accounts": scanned 30000 of 166667 pages, containing 360000 live rows and 200 dead rows; 30000 rows in sample, 2000004 estimated total rows
|
114
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:21.078-05:00","v":0,"msg":"Replaying rows, count: 2000","version":"0.4.0"}
|
115
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:21.580-05:00","v":0,"msg":"Replaying rows, count: 2000","version":"0.4.0"}
|
116
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:22.022-05:00","v":0,"msg":"Replaying rows, count: 2000","version":"0.4.0"}
|
117
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:22.490-05:00","v":0,"msg":"Replaying rows, count: 2000","version":"0.4.0"}
|
118
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:22.866-05:00","v":0,"msg":"Replaying rows, count: 661","version":"0.4.0"}
|
119
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:23.212-05:00","v":0,"msg":"Replaying rows, count: 533","version":"0.4.0"}
|
120
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:23.512-05:00","v":0,"msg":"Replaying rows, count: 468","version":"0.4.0"}
|
121
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:23.809-05:00","v":0,"msg":"Remaining rows below delta count, proceeding towards swap","version":"0.4.0"}
|
122
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:23.809-05:00","v":0,"msg":"Performing swap!","version":"0.4.0"}
|
123
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:24.259-05:00","v":0,"msg":"Replaying rows, count: 449","version":"0.4.0"}
|
124
|
+
NOTICE: trigger "primary_to_audit_table_trigger" for relation "pgbench_accounts" does not exist, skipping
|
125
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:24.650-05:00","v":0,"msg":"Performing ANALYZE!","version":"0.4.0"}
|
126
|
+
INFO: analyzing "public.pgbench_accounts"
|
127
|
+
INFO: "pgbench_accounts": scanned 30000 of 32935 pages, containing 1821834 live rows and 6056 dead rows; 30000 rows in sample, 2000070 estimated total rows
|
128
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:24.941-05:00","v":0,"msg":"Validating constraints!","version":"0.4.0"}
|
129
|
+
NOTICE: table "pgosc_st_pgbench_accounts_714a8b" does not exist, skipping
|
130
|
+
{"name":"pg-online-schema-change","hostname":"MacBook-Pro.local","pid":13263,"level":30,"time":"2022-02-25T17:22:26.159-05:00","v":0,"msg":"All tasks successfully completed","version":"0.4.0"}
|
131
|
+
```
|
132
|
+
|
133
|
+
</details>
|
134
|
+
|
135
|
+
|
136
|
+
## Conclusion
|
137
|
+
|
138
|
+
By tweaking `--pull-batch-count` to `2000` (replay 2k rows at once) and `--delta-count` to `200` (time to swap when remaining rows is <200), `pg-osc` was able to perform the schema change with no impact within very quick time. Depending on the database size and load on the table, you can further tune them to achieve desired impact. At some point this is going to plateau - I can imagine the replay factor not working quite well for say 100k commits/s workloads. So, YMMV.
|
@@ -1,8 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "thor"
|
2
4
|
|
3
5
|
module PgOnlineSchemaChange
|
6
|
+
PULL_BATCH_COUNT = 1000
|
7
|
+
DELTA_COUNT = 20
|
4
8
|
class CLI < Thor
|
5
|
-
desc "perform", "
|
9
|
+
desc "perform", "Safely apply schema changes with minimal locks"
|
6
10
|
method_option :alter_statement, aliases: "-a", type: :string, required: true,
|
7
11
|
desc: "The ALTER statement to perform the schema change"
|
8
12
|
method_option :schema, aliases: "-s", type: :string, required: true, default: "public",
|
@@ -11,7 +15,7 @@ module PgOnlineSchemaChange
|
|
11
15
|
method_option :host, aliases: "-h", type: :string, required: true, desc: "Server host where the Database is located"
|
12
16
|
method_option :username, aliases: "-u", type: :string, required: true, desc: "Username for the Database"
|
13
17
|
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"
|
18
|
+
method_option :password, aliases: "-w", type: :string, required: true, desc: "DEPRECATED: Password for the Database. Please pass PGPASSWORD environment variable instead."
|
15
19
|
method_option :verbose, aliases: "-v", type: :boolean, default: false, desc: "Emit logs in debug mode"
|
16
20
|
method_option :drop, aliases: "-f", type: :boolean, default: false,
|
17
21
|
desc: "Drop the original table in the end after the swap"
|
@@ -21,11 +25,19 @@ module PgOnlineSchemaChange
|
|
21
25
|
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
26
|
method_option :copy_statement, aliases: "-c", type: :string, required: false, default: "",
|
23
27
|
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."
|
28
|
+
method_option :pull_batch_count, aliases: "-b", type: :numeric, required: false, default: PULL_BATCH_COUNT,
|
29
|
+
desc: "Number of rows to be replayed on each iteration after copy. This can be tuned for faster catch up and swap. Best used with delta-count."
|
30
|
+
method_option :delta_count, aliases: "-e", type: :numeric, required: false, default: DELTA_COUNT,
|
31
|
+
desc: "Indicates how many rows should be remaining before a swap should be performed. This can be tuned for faster catch up and swap, especially on highly volume tables. Best used with pull-batch-count."
|
24
32
|
|
25
33
|
def perform
|
26
34
|
client_options = Struct.new(*options.keys.map(&:to_sym)).new(*options.values)
|
35
|
+
PgOnlineSchemaChange.logger(verbose: client_options.verbose)
|
36
|
+
|
37
|
+
PgOnlineSchemaChange.logger.warn("DEPRECATED: -w is deprecated. Please pass PGPASSWORD environment variable instead.") if client_options.password
|
38
|
+
|
39
|
+
client_options.password = ENV["PGPASSWORD"] || client_options.password
|
27
40
|
|
28
|
-
PgOnlineSchemaChange.logger = client_options.verbose
|
29
41
|
PgOnlineSchemaChange::Orchestrate.run!(client_options)
|
30
42
|
end
|
31
43
|
|
@@ -1,9 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "pg"
|
2
4
|
|
3
5
|
module PgOnlineSchemaChange
|
4
6
|
class Client
|
5
7
|
attr_accessor :alter_statement, :schema, :dbname, :host, :username, :port, :password, :connection, :table, :drop,
|
6
|
-
:kill_backends, :wait_time_for_lock, :copy_statement
|
8
|
+
:kill_backends, :wait_time_for_lock, :copy_statement, :pull_batch_count, :delta_count
|
7
9
|
|
8
10
|
def initialize(options)
|
9
11
|
@alter_statement = options.alter_statement
|
@@ -16,7 +18,11 @@ module PgOnlineSchemaChange
|
|
16
18
|
@drop = options.drop
|
17
19
|
@kill_backends = options.kill_backends
|
18
20
|
@wait_time_for_lock = options.wait_time_for_lock
|
21
|
+
@pull_batch_count = options.pull_batch_count
|
22
|
+
@delta_count = options.delta_count
|
23
|
+
|
19
24
|
handle_copy_statement(options.copy_statement)
|
25
|
+
handle_validations
|
20
26
|
|
21
27
|
@connection = PG.connect(
|
22
28
|
dbname: @dbname,
|
@@ -26,17 +32,19 @@ module PgOnlineSchemaChange
|
|
26
32
|
port: @port,
|
27
33
|
)
|
28
34
|
|
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
35
|
@table = Query.table(@alter_statement)
|
36
36
|
|
37
37
|
PgOnlineSchemaChange.logger.debug("Connection established")
|
38
38
|
end
|
39
39
|
|
40
|
+
def handle_validations
|
41
|
+
raise Error, "Not a valid ALTER statement: #{@alter_statement}" unless Query.alter_statement?(@alter_statement)
|
42
|
+
|
43
|
+
return if Query.same_table?(@alter_statement)
|
44
|
+
|
45
|
+
raise Error "All statements should belong to the same table: #{@alter_statement}"
|
46
|
+
end
|
47
|
+
|
40
48
|
def handle_copy_statement(statement)
|
41
49
|
return if statement.nil? || statement == ""
|
42
50
|
|
@@ -1,4 +1,6 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
FUNC_FIX_SERIAL_SEQUENCE = <<~SQL
|
2
4
|
CREATE OR REPLACE FUNCTION fix_serial_sequence(_table regclass, _newtable text)
|
3
5
|
RETURNS void AS
|
4
6
|
$func$
|
@@ -35,7 +37,7 @@ FUNC_FIX_SERIAL_SEQUENCE = <<~SQL.freeze
|
|
35
37
|
$func$ LANGUAGE plpgsql VOLATILE;
|
36
38
|
SQL
|
37
39
|
|
38
|
-
FUNC_CREATE_TABLE_ALL = <<~SQL
|
40
|
+
FUNC_CREATE_TABLE_ALL = <<~SQL
|
39
41
|
CREATE OR REPLACE FUNCTION create_table_all(source_table text, newsource_table text)
|
40
42
|
RETURNS void language plpgsql
|
41
43
|
as $$
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module PgOnlineSchemaChange
|
2
4
|
module Helper
|
3
5
|
def primary_key
|
@@ -15,7 +17,14 @@ module PgOnlineSchemaChange
|
|
15
17
|
result = Store.send(:get, method)
|
16
18
|
return result if result
|
17
19
|
|
18
|
-
|
20
|
+
super
|
21
|
+
end
|
22
|
+
|
23
|
+
def respond_to_missing?(method_name, *args)
|
24
|
+
result = Store.send(:get, method)
|
25
|
+
return true if result
|
26
|
+
|
27
|
+
super
|
19
28
|
end
|
20
29
|
end
|
21
30
|
end
|
@@ -1,8 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "securerandom"
|
2
4
|
|
3
5
|
module PgOnlineSchemaChange
|
4
6
|
class Orchestrate
|
5
|
-
SWAP_STATEMENT_TIMEOUT = "5s"
|
7
|
+
SWAP_STATEMENT_TIMEOUT = "5s"
|
6
8
|
|
7
9
|
extend Helper
|
8
10
|
|
@@ -21,12 +23,20 @@ module PgOnlineSchemaChange
|
|
21
23
|
Query.run(client.connection, FUNC_FIX_SERIAL_SEQUENCE)
|
22
24
|
Query.run(client.connection, FUNC_CREATE_TABLE_ALL)
|
23
25
|
|
26
|
+
setup_store
|
27
|
+
end
|
28
|
+
|
29
|
+
def setup_store
|
24
30
|
# Set this early on to ensure their creation and cleanup (unexpected)
|
25
31
|
# happens at all times. IOW, the calls from Store.get always return
|
26
32
|
# the same value.
|
27
33
|
Store.set(:old_primary_table, "pgosc_op_table_#{client.table}")
|
28
|
-
Store.set(:audit_table, "pgosc_at_#{client.table}_#{
|
29
|
-
Store.set(:
|
34
|
+
Store.set(:audit_table, "pgosc_at_#{client.table}_#{pgosc_identifier}")
|
35
|
+
Store.set(:operation_type_column, "operation_type_#{pgosc_identifier}")
|
36
|
+
Store.set(:trigger_time_column, "trigger_time_#{pgosc_identifier}")
|
37
|
+
Store.set(:audit_table_pk, "at_#{pgosc_identifier}_id")
|
38
|
+
Store.set(:audit_table_pk_sequence, "#{audit_table}_#{audit_table_pk}_seq")
|
39
|
+
Store.set(:shadow_table, "pgosc_st_#{client.table}_#{pgosc_identifier}")
|
30
40
|
end
|
31
41
|
|
32
42
|
def run!(options)
|
@@ -70,7 +80,7 @@ module PgOnlineSchemaChange
|
|
70
80
|
reader = setup_signals!
|
71
81
|
signal = reader.gets.chomp
|
72
82
|
|
73
|
-
while !reader.closed? && IO.select([reader])
|
83
|
+
while !reader.closed? && IO.select([reader]) # rubocop:disable Lint/UnreachableLoop
|
74
84
|
logger.info "Signal #{signal} received, cleaning up"
|
75
85
|
|
76
86
|
client.connection.cancel
|
@@ -85,7 +95,7 @@ module PgOnlineSchemaChange
|
|
85
95
|
logger.info("Setting up audit table", { audit_table: audit_table })
|
86
96
|
|
87
97
|
sql = <<~SQL
|
88
|
-
CREATE TABLE #{audit_table} (
|
98
|
+
CREATE TABLE #{audit_table} (#{audit_table_pk} SERIAL PRIMARY KEY, #{operation_type_column} text, #{trigger_time_column} timestamp, LIKE #{client.table});
|
89
99
|
SQL
|
90
100
|
|
91
101
|
Query.run(client.connection, sql)
|
@@ -109,13 +119,13 @@ module PgOnlineSchemaChange
|
|
109
119
|
$$
|
110
120
|
BEGIN
|
111
121
|
IF ( TG_OP = 'INSERT') THEN
|
112
|
-
INSERT INTO \"#{audit_table}\" select 'INSERT',
|
122
|
+
INSERT INTO \"#{audit_table}\" select nextval(\'#{audit_table_pk_sequence}\'), 'INSERT', clock_timestamp(), NEW.* ;
|
113
123
|
RETURN NEW;
|
114
124
|
ELSIF ( TG_OP = 'UPDATE') THEN
|
115
|
-
INSERT INTO \"#{audit_table}\" select 'UPDATE',
|
125
|
+
INSERT INTO \"#{audit_table}\" select nextval(\'#{audit_table_pk_sequence}\'), 'UPDATE', clock_timestamp(), NEW.* ;
|
116
126
|
RETURN NEW;
|
117
127
|
ELSIF ( TG_OP = 'DELETE') THEN
|
118
|
-
INSERT INTO \"#{audit_table}\" select 'DELETE',
|
128
|
+
INSERT INTO \"#{audit_table}\" select nextval(\'#{audit_table_pk_sequence}\'), 'DELETE', clock_timestamp(), OLD.* ;
|
119
129
|
RETURN NEW;
|
120
130
|
END IF;
|
121
131
|
END;
|
@@ -153,7 +163,7 @@ module PgOnlineSchemaChange
|
|
153
163
|
# re-uses transaction with serializable
|
154
164
|
# Disabling vacuum to avoid any issues during the process
|
155
165
|
result = Query.storage_parameters_for(client, client.table, true) || ""
|
156
|
-
|
166
|
+
Store.set(:primary_table_storage_parameters, result)
|
157
167
|
|
158
168
|
logger.debug("Disabling vacuum on shadow and audit table",
|
159
169
|
{ shadow_table: shadow_table, audit_table: audit_table })
|
@@ -185,8 +195,7 @@ module PgOnlineSchemaChange
|
|
185
195
|
# Begin the process to copy data into copy table
|
186
196
|
# depending on the size of the table, this can be a time
|
187
197
|
# taking operation.
|
188
|
-
logger.info("Clearing contents of audit table before copy..",
|
189
|
-
{ shadow_table: shadow_table, parent_table: client.table })
|
198
|
+
logger.info("Clearing contents of audit table before copy..", { shadow_table: shadow_table, parent_table: client.table })
|
190
199
|
Query.run(client.connection, "DELETE FROM #{audit_table}", true)
|
191
200
|
|
192
201
|
logger.info("Copying contents..", { shadow_table: shadow_table, parent_table: client.table })
|
@@ -195,7 +204,7 @@ module PgOnlineSchemaChange
|
|
195
204
|
return Query.run(client.connection, query, true)
|
196
205
|
end
|
197
206
|
|
198
|
-
sql = Query.copy_data_statement(client, shadow_table)
|
207
|
+
sql = Query.copy_data_statement(client, shadow_table, true)
|
199
208
|
Query.run(client.connection, sql, true)
|
200
209
|
ensure
|
201
210
|
Query.run(client.connection, "COMMIT;") # commit the serializable transaction
|
@@ -272,8 +281,10 @@ module PgOnlineSchemaChange
|
|
272
281
|
Query.run(client.connection, sql)
|
273
282
|
end
|
274
283
|
|
275
|
-
private
|
276
|
-
|
284
|
+
private
|
285
|
+
|
286
|
+
def pgosc_identifier
|
287
|
+
@pgosc_identifier ||= SecureRandom.hex(3)
|
277
288
|
end
|
278
289
|
end
|
279
290
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "pg_query"
|
2
4
|
require "pg"
|
3
5
|
|
@@ -5,7 +7,7 @@ module PgOnlineSchemaChange
|
|
5
7
|
class Query
|
6
8
|
extend Helper
|
7
9
|
|
8
|
-
INDEX_SUFFIX = "_pgosc"
|
10
|
+
INDEX_SUFFIX = "_pgosc"
|
9
11
|
DROPPED_COLUMN_TYPE = :AT_DropColumn
|
10
12
|
RENAMED_COLUMN_TYPE = :AT_RenameColumn
|
11
13
|
LOCK_ATTEMPT = 4
|
@@ -15,28 +17,28 @@ module PgOnlineSchemaChange
|
|
15
17
|
PgQuery.parse(query).tree.stmts.all? do |statement|
|
16
18
|
statement.stmt.alter_table_stmt.instance_of?(PgQuery::AlterTableStmt) || statement.stmt.rename_stmt.instance_of?(PgQuery::RenameStmt)
|
17
19
|
end
|
18
|
-
rescue PgQuery::ParseError
|
20
|
+
rescue PgQuery::ParseError
|
19
21
|
false
|
20
22
|
end
|
21
23
|
|
22
24
|
def same_table?(query)
|
23
|
-
tables = PgQuery.parse(query).tree.stmts.
|
25
|
+
tables = PgQuery.parse(query).tree.stmts.filter_map do |statement|
|
24
26
|
if statement.stmt.alter_table_stmt.instance_of?(PgQuery::AlterTableStmt)
|
25
27
|
statement.stmt.alter_table_stmt.relation.relname
|
26
28
|
elsif statement.stmt.rename_stmt.instance_of?(PgQuery::RenameStmt)
|
27
29
|
statement.stmt.rename_stmt.relation.relname
|
28
30
|
end
|
29
|
-
end
|
31
|
+
end
|
30
32
|
|
31
33
|
tables.uniq.count == 1
|
32
|
-
rescue PgQuery::ParseError
|
34
|
+
rescue PgQuery::ParseError
|
33
35
|
false
|
34
36
|
end
|
35
37
|
|
36
38
|
def table(query)
|
37
|
-
from_rename_statement = PgQuery.parse(query).tree.stmts.
|
39
|
+
from_rename_statement = PgQuery.parse(query).tree.stmts.filter_map do |statement|
|
38
40
|
statement.stmt.rename_stmt&.relation&.relname
|
39
|
-
end
|
41
|
+
end[0]
|
40
42
|
PgQuery.parse(query).tables[0] || from_rename_statement
|
41
43
|
end
|
42
44
|
|
@@ -48,7 +50,7 @@ module PgOnlineSchemaChange
|
|
48
50
|
connection.async_exec("BEGIN;")
|
49
51
|
|
50
52
|
result = connection.async_exec(query, &block)
|
51
|
-
rescue Exception
|
53
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
52
54
|
connection.cancel if connection.transaction_status != PG::PQTRANS_IDLE
|
53
55
|
connection.block
|
54
56
|
logger.info("Exception raised, rolling back query", { rollback: true, query: query })
|
@@ -144,11 +146,11 @@ module PgOnlineSchemaChange
|
|
144
146
|
end
|
145
147
|
|
146
148
|
references.map do |row|
|
147
|
-
if row["definition"].end_with?("NOT VALID")
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
149
|
+
add_statement = if row["definition"].end_with?("NOT VALID")
|
150
|
+
"ALTER TABLE #{row["table_on"]} ADD CONSTRAINT #{row["constraint_name"]} #{row["definition"]};"
|
151
|
+
else
|
152
|
+
"ALTER TABLE #{row["table_on"]} ADD CONSTRAINT #{row["constraint_name"]} #{row["definition"]} NOT VALID;"
|
153
|
+
end
|
152
154
|
|
153
155
|
drop_statement = "ALTER TABLE #{row["table_on"]} DROP CONSTRAINT #{row["constraint_name"]};"
|
154
156
|
|
@@ -291,7 +293,7 @@ module PgOnlineSchemaChange
|
|
291
293
|
client.connection.quote_ident(select_column)
|
292
294
|
end
|
293
295
|
|
294
|
-
|
296
|
+
<<~SQL
|
295
297
|
INSERT INTO #{shadow_table}(#{insert_into_columns.join(", ")})
|
296
298
|
SELECT #{select_columns.join(", ")}
|
297
299
|
FROM ONLY #{client.table}
|
@@ -1,12 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
4
|
+
|
1
5
|
module PgOnlineSchemaChange
|
2
6
|
class Replay
|
3
7
|
extend Helper
|
4
8
|
|
5
9
|
class << self
|
6
|
-
PULL_BATCH_COUNT = 1000
|
7
|
-
DELTA_COUNT = 20
|
8
|
-
RESERVED_COLUMNS = %w[operation_type trigger_time].freeze
|
9
|
-
|
10
10
|
# This, picks PULL_BATCH_COUNT rows by primary key from audit_table,
|
11
11
|
# replays it on the shadow_table. Once the batch is done,
|
12
12
|
# it them deletes those PULL_BATCH_COUNT rows from audit_table. Then, pull another batch,
|
@@ -17,7 +17,7 @@ module PgOnlineSchemaChange
|
|
17
17
|
loop do
|
18
18
|
rows = rows_to_play
|
19
19
|
|
20
|
-
raise CountBelowDelta if rows.count <=
|
20
|
+
raise CountBelowDelta if rows.count <= client.delta_count
|
21
21
|
|
22
22
|
play!(rows)
|
23
23
|
end
|
@@ -25,7 +25,7 @@ module PgOnlineSchemaChange
|
|
25
25
|
|
26
26
|
def rows_to_play(reuse_trasaction = false)
|
27
27
|
select_query = <<~SQL
|
28
|
-
SELECT * FROM #{audit_table} ORDER BY #{
|
28
|
+
SELECT * FROM #{audit_table} ORDER BY #{audit_table_pk} LIMIT #{client.pull_batch_count};
|
29
29
|
SQL
|
30
30
|
|
31
31
|
rows = []
|
@@ -34,6 +34,10 @@ module PgOnlineSchemaChange
|
|
34
34
|
rows
|
35
35
|
end
|
36
36
|
|
37
|
+
def reserved_columns
|
38
|
+
@reserved_columns ||= [trigger_time_column, operation_type_column, audit_table_pk]
|
39
|
+
end
|
40
|
+
|
37
41
|
def play!(rows, reuse_trasaction = false)
|
38
42
|
logger.info("Replaying rows, count: #{rows.size}")
|
39
43
|
|
@@ -44,7 +48,7 @@ module PgOnlineSchemaChange
|
|
44
48
|
|
45
49
|
# Remove audit table cols, since we will be
|
46
50
|
# re-mapping them for inserts and updates
|
47
|
-
|
51
|
+
reserved_columns.each do |col|
|
48
52
|
new_row.delete(col)
|
49
53
|
end
|
50
54
|
|
@@ -73,7 +77,7 @@ module PgOnlineSchemaChange
|
|
73
77
|
client.connection.escape_string(value)
|
74
78
|
end
|
75
79
|
|
76
|
-
case row[
|
80
|
+
case row[operation_type_column]
|
77
81
|
when "INSERT"
|
78
82
|
values = new_row.map { |_, val| "'#{val}'" }.join(",")
|
79
83
|
|
@@ -110,13 +114,15 @@ module PgOnlineSchemaChange
|
|
110
114
|
Query.run(client.connection, to_be_replayed.join, reuse_trasaction)
|
111
115
|
|
112
116
|
# Delete items from the audit now that are replayed
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
117
|
+
return unless to_be_deleted_rows.count >= 1
|
118
|
+
|
119
|
+
delete_query = <<~SQL
|
120
|
+
DELETE FROM #{audit_table} WHERE #{primary_key} IN (#{to_be_deleted_rows.join(",")})
|
121
|
+
SQL
|
122
|
+
Query.run(client.connection, delete_query, reuse_trasaction)
|
119
123
|
end
|
120
124
|
end
|
121
125
|
end
|
122
126
|
end
|
127
|
+
|
128
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
@@ -1,17 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "pg_query"
|
2
4
|
require "pg"
|
3
5
|
|
4
6
|
module PgOnlineSchemaChange
|
5
7
|
class Store
|
6
8
|
class << self
|
7
|
-
|
9
|
+
@object = {}
|
8
10
|
|
9
11
|
def get(key)
|
10
|
-
|
12
|
+
@object ||= {}
|
13
|
+
@object[key.to_s] || @object[key.to_sym]
|
11
14
|
end
|
12
15
|
|
13
16
|
def set(key, value)
|
14
|
-
|
17
|
+
@object ||= {}
|
18
|
+
@object[key.to_sym] = value
|
15
19
|
end
|
16
20
|
end
|
17
21
|
end
|
@@ -6,28 +6,24 @@ require "ougai"
|
|
6
6
|
require "pg_online_schema_change/version"
|
7
7
|
require "pg_online_schema_change/helper"
|
8
8
|
require "pg_online_schema_change/functions"
|
9
|
-
require "pg_online_schema_change/cli"
|
10
9
|
require "pg_online_schema_change/client"
|
11
10
|
require "pg_online_schema_change/query"
|
12
11
|
require "pg_online_schema_change/store"
|
13
12
|
require "pg_online_schema_change/replay"
|
14
13
|
require "pg_online_schema_change/orchestrate"
|
14
|
+
require "pg_online_schema_change/cli"
|
15
15
|
|
16
16
|
module PgOnlineSchemaChange
|
17
17
|
class Error < StandardError; end
|
18
18
|
class CountBelowDelta < StandardError; end
|
19
19
|
class AccessExclusiveLockNotAcquired < StandardError; end
|
20
20
|
|
21
|
-
def self.logger
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
end
|
29
|
-
|
30
|
-
def self.logger
|
31
|
-
@@logger
|
21
|
+
def self.logger(verbose: false)
|
22
|
+
@logger ||= begin
|
23
|
+
logger = Ougai::Logger.new($stdout)
|
24
|
+
logger.level = verbose ? Ougai::Logger::TRACE : Ougai::Logger::INFO
|
25
|
+
logger.with_fields = { version: PgOnlineSchemaChange::VERSION }
|
26
|
+
logger
|
27
|
+
end
|
32
28
|
end
|
33
29
|
end
|
data/scripts/release.sh
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
export VERSION=$1
|
2
|
+
echo "VERSION: ${VERSION}"
|
3
|
+
|
4
|
+
echo "=== Pushing tags to github ===="
|
5
|
+
git tag v$VERSION
|
6
|
+
git push origin --tags
|
7
|
+
|
8
|
+
echo "=== Building Gem ===="
|
9
|
+
gem build pg_online_schema_change.gemspec
|
10
|
+
|
11
|
+
echo "=== Pushing gem ===="
|
12
|
+
gem push pg_online_schema_change-$VERSION.gem
|
13
|
+
|
14
|
+
echo "=== Sleeping for 5s ===="
|
15
|
+
sleep 5
|
16
|
+
|
17
|
+
echo "=== Building Image ===="
|
18
|
+
docker build . --build-arg VERSION=$VERSION -t shayonj/pg-osc:$VERSION
|
19
|
+
|
20
|
+
echo "=== Tagging Image ===="
|
21
|
+
docker image tag shayonj/pg-osc:$VERSION shayonj/pg-osc:latest
|
22
|
+
|
23
|
+
echo "=== Pushing Image ===="
|
24
|
+
docker push shayonj/pg-osc:$VERSION
|
25
|
+
docker push shayonj/pg-osc:latest
|
26
|
+
|
27
|
+
echo "=== Cleaning up ===="
|
28
|
+
rm pg_online_schema_change-$VERSION.gem
|