pg_easy_replicate 0.2.7 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90a3489ff7f33639118b99b142c2dcdef491ad384df80de0cceee89b706a9073
4
- data.tar.gz: 0ab288970a9eb6465878ef03aca0d67f7543426a4cc744a4e47592ac200f5b68
3
+ metadata.gz: 846d76c9da382a82b36243070402e2e42d7135c6ccd52a1308b4d4ebdb15e4ca
4
+ data.tar.gz: 159925fe9649bc527a861f4e5eac52102af05542ddfeb8f742e9077a1764fe38
5
5
  SHA512:
6
- metadata.gz: e487ac3b147855379c230d3b792d702d42776eb254f227cf31a8ebc5dee677cbf47b2efd7a9c0c218a87f064b1449e3c286d1dbc6c0f50f6b108cee3b1a5ccaa
7
- data.tar.gz: 5ce88fff42f5ba68977b15261376d48e23173d210eddd1beccdad148cafab45f8e760a09d37991cef491313b4dee12dfa761b495fa1b90e8348c2b89ed2c3a6a
6
+ metadata.gz: 512c5ec0e049476d5e02a549effd6d9032642e59eda2507a6a611fae2dfcb136c1efb8fab58f1dd46b12a38dd758195792424415b97e445ad21cb28737048a2c
7
+ data.tar.gz: c36d0cfaebf53e89b58bbdcd52f8394d67196f912bcaaeb2247122bbb1a431d7b850d6c1355058cb810ea718ec9cb268204b568c36aeca98b90174ed67544714
data/Gemfile.lock CHANGED
@@ -1,10 +1,11 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pg_easy_replicate (0.2.7)
4
+ pg_easy_replicate (0.3.0)
5
5
  ougai (~> 2.0.0)
6
6
  pg (~> 1.5.3)
7
- sequel (>= 5.69, < 5.83)
7
+ pg_query (~> 5.1.0)
8
+ sequel (>= 5.69, < 5.84)
8
9
  thor (>= 1.2.2, < 1.4.0)
9
10
 
10
11
  GEM
@@ -14,6 +15,12 @@ GEM
14
15
  bigdecimal (3.1.8)
15
16
  coderay (1.1.3)
16
17
  diff-lcs (1.5.1)
18
+ google-protobuf (4.28.0-arm64-darwin)
19
+ bigdecimal
20
+ rake (>= 13)
21
+ google-protobuf (4.28.0-x86_64-linux)
22
+ bigdecimal
23
+ rake (>= 13)
17
24
  haml (6.1.1)
18
25
  temple (>= 0.8.2)
19
26
  thor
@@ -29,6 +36,8 @@ GEM
29
36
  ast (~> 2.4.1)
30
37
  racc
31
38
  pg (1.5.7)
39
+ pg_query (5.1.0)
40
+ google-protobuf (>= 3.22.3)
32
41
  prettier_print (1.2.1)
33
42
  pry (0.14.2)
34
43
  coderay (~> 1.1)
@@ -38,7 +47,7 @@ GEM
38
47
  rake (13.2.1)
39
48
  rbs (3.1.0)
40
49
  regexp_parser (2.9.2)
41
- rexml (3.3.3)
50
+ rexml (3.3.6)
42
51
  strscan
43
52
  rspec (3.13.0)
44
53
  rspec-core (~> 3.13.0)
@@ -85,7 +94,7 @@ GEM
85
94
  rubocop-rspec_rails (2.28.2)
86
95
  rubocop (~> 1.40)
87
96
  ruby-progressbar (1.13.0)
88
- sequel (5.82.0)
97
+ sequel (5.83.1)
89
98
  bigdecimal
90
99
  strscan (3.1.0)
91
100
  syntax_tree (6.2.0)
data/README.md CHANGED
@@ -22,9 +22,14 @@ Battle tested in production at [Tines](https://www.tines.com/) 🚀
22
22
  - [Config Check](#config-check-1)
23
23
  - [Bootstrap](#bootstrap-1)
24
24
  - [Start sync](#start-sync)
25
+ - [DDL Changes Management](#ddl-changes-management)
26
+ - [Listing DDL Changes](#listing-ddl-changes)
27
+ - [Applying DDL Changes](#applying-ddl-changes)
25
28
  - [Stats](#stats)
26
29
  - [Performing switchover](#performing-switchover)
27
30
  - [Replicating single database with custom tables](#replicating-single-database-with-custom-tables)
31
+ - [Exclude tables from replication](#exclude-tables-from-replication)
32
+ - [Cleanup](#cleanup)
28
33
  - [Switchover strategies with minimal downtime](#switchover-strategies-with-minimal-downtime)
29
34
  - [Rolling restart strategy](#rolling-restart-strategy)
30
35
  - [DNS Failover strategy](#dns-failover-strategy)
@@ -69,11 +74,13 @@ All [Logical Replication Restrictions](https://www.postgresql.org/docs/current/l
69
74
 
70
75
  ## Usage
71
76
 
72
- Ensure `SOURCE_DB_URL` and `TARGET_DB_URL` are present as environment variables in the runtime environment.
77
+ Ensure `SOURCE_DB_URL` and `TARGET_DB_URL` are present as environment variables in the runtime environment.
78
+
73
79
  - `SOURCE_DB_URL` = The database that you want to replicate FROM.
74
80
  - `TARGET_DB_URL` = The database that you want to replicate TO.
75
81
 
76
82
  The URL should be in postgres connection string format. Example:
83
+
77
84
  ```bash
78
85
  $ export SOURCE_DB_URL="postgres://USERNAME:PASSWORD@localhost:5432/DATABASE_NAME"
79
86
  $ export TARGET_DB_URL="postgres://USERNAME:PASSWORD@localhost:5433/DATABASE_NAME"
@@ -174,12 +181,62 @@ Once the bootstrap is complete, you can start the sync. Starting the sync sets u
174
181
  **NOTE**: Start sync by default will drop all indices in the target database for performance reasons. And will automatically re-add the indices during `switchover`. It is turned on by default and you can opt out of this with `--no-recreate-indices-post-copy`
175
182
 
176
183
  ```bash
177
- $ pg_easy_replicate start_sync --group-name database-cluster-1
184
+ $ pg_easy_replicate start_sync --group-name database-cluster-1 [-d <track-ddl>]
178
185
 
179
186
  {"name":"pg_easy_replicate","hostname":"PKHXQVK6DW","pid":22113,"level":30,"time":"2023-06-19T15:54:54.874-04:00","v":0,"msg":"Setting up publication","publication_name":"pger_publication_database_cluster_1","version":"0.1.0"}
180
187
  ...
181
188
  ```
182
189
 
190
+ ### DDL Changes Management
191
+
192
+ `pg_easy_replicate` now supports tracking and applying DDL (Data Definition Language) changes between the source and target databases. To track DDLs you can pass `-track-ddl` to `start_sync`.
193
+
194
+ This feature ensures that most schema changes made to the source database tables that are being replicated during the replication process are tracked, so that you can apply them at your will before or after switchover.
195
+
196
+ #### Listing DDL Changes
197
+
198
+ To view the DDL changes that have been tracked:
199
+
200
+ ```bash
201
+ $ pg_easy_replicate list_ddl_changes -g <group-name> [-l <limit>]
202
+ ```
203
+
204
+ This command will display a list of DDL changes in JSON format;
205
+
206
+ ```
207
+ [
208
+ {
209
+ "id": 1,
210
+ "group_name": "cluster-1",
211
+ "event_type": "ddl_command_end",
212
+ "object_type": "table",
213
+ "object_identity": "public.pgbench_accounts",
214
+ "ddl_command": "ALTER TABLE public.pgbench_accounts ADD COLUMN test_column VARCHAR(255)",
215
+ "created_at": "2024-08-31 15:42:33 UTC"
216
+ }
217
+ ]
218
+ ```
219
+
220
+ #### Applying DDL Changes
221
+
222
+ `pg_easy_replicate` won't automatically apply the changes for you. To apply the tracked DDL changes to the target database:
223
+
224
+ ```bash
225
+ $ pg_easy_replicate apply_ddl_change -g <group-name> [-i <change-id>]
226
+ ```
227
+
228
+ If you specify a change ID with the `-i` option, only that specific change will be applied. If you don't specify an ID, you'll be prompted to apply all pending changes.
229
+
230
+ ```bash
231
+ $ pg_easy_replicate apply_ddl_change -g cluster-1
232
+ The following DDL changes will be applied:
233
+ ID: 1, Type: table, Command: ALTER TABLE public.pgbench_accounts ADD COLUMN test_column VARCHAR(255)...
234
+
235
+ Do you want to apply all these changes? (y/n): y
236
+ ...
237
+ All pending DDL changes applied successfully.
238
+ ```
239
+
183
240
  ### Stats
184
241
 
185
242
  You can inspect or watch stats any time during the sync process. The stats give you an idea of when the sync started, current flush/write lag, how many tables are in `replicating`, `copying` or other stages, and more.
data/docker-compose.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  version: "3.7"
2
2
  services:
3
3
  source_db:
4
- image: postgres:12
4
+ image: postgres:14
5
5
  ports:
6
6
  - "5432:5432"
7
7
  environment:
@@ -9,6 +9,7 @@ services:
9
9
  POSTGRES_PASSWORD: james-bond123@7!'3aaR
10
10
  POSTGRES_DB: postgres-db
11
11
  command: >
12
+ -c max_connections=200
12
13
  -c wal_level=logical
13
14
  -c ssl=on
14
15
  -c ssl_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
@@ -25,6 +26,7 @@ services:
25
26
  POSTGRES_PASSWORD: james-bond123@7!'3aaR
26
27
  POSTGRES_DB: postgres-db
27
28
  command: >
29
+ -c max_connections=200
28
30
  -c wal_level=logical
29
31
  -c ssl=on
30
32
  -c ssl_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
@@ -54,6 +54,11 @@ module PgEasyReplicate
54
54
  aliases: "-c",
55
55
  boolean: true,
56
56
  desc: "Copy schema to the new database"
57
+ method_option :track_ddl,
58
+ aliases: "-d",
59
+ type: :boolean,
60
+ default: false,
61
+ desc: "Enable DDL tracking for the group"
57
62
  desc "bootstrap",
58
63
  "Sets up temporary tables for information required during runtime"
59
64
  def bootstrap
@@ -83,11 +88,6 @@ module PgEasyReplicate
83
88
  aliases: "-g",
84
89
  required: true,
85
90
  desc: "Name of the group to provision"
86
- method_option :group_name,
87
- aliases: "-g",
88
- required: true,
89
- desc:
90
- "Name of the grouping for this collection of source and target DB"
91
91
  method_option :schema_name,
92
92
  aliases: "-s",
93
93
  desc:
@@ -104,10 +104,14 @@ module PgEasyReplicate
104
104
  "Comma separated list of table names to exclude. Default: None"
105
105
  method_option :recreate_indices_post_copy,
106
106
  type: :boolean,
107
- default: true,
107
+ default: false,
108
108
  aliases: "-r",
109
109
  desc:
110
110
  "Drop all non-primary indices before copy and recreate them post-copy"
111
+ method_option :track_ddl,
112
+ type: :boolean,
113
+ default: false,
114
+ desc: "Enable DDL tracking for the group"
111
115
  def start_sync
112
116
  PgEasyReplicate::Orchestrate.start_sync(options)
113
117
  end
@@ -122,7 +126,7 @@ module PgEasyReplicate
122
126
  PgEasyReplicate::Orchestrate.stop_sync(group_name: options[:group_name])
123
127
  end
124
128
 
125
- desc "switchover ",
129
+ desc "switchover",
126
130
  "Puts the source database in read only mode after all the data is flushed and written"
127
131
  method_option :group_name,
128
132
  aliases: "-g",
@@ -130,21 +134,18 @@ module PgEasyReplicate
130
134
  desc: "Name of the group previously provisioned"
131
135
  method_option :lag_delta_size,
132
136
  aliases: "-l",
133
- desc: "The size of the lag to watch for before switchover. Default 200KB."
137
+ desc:
138
+ "The size of the lag to watch for before switchover. Default 200KB."
134
139
  method_option :skip_vacuum_analyze,
135
140
  type: :boolean,
136
141
  default: false,
137
142
  aliases: "-s",
138
143
  desc: "Skip vacuum analyzing tables before switchover."
139
- # method_option :bi_directional,
140
- # aliases: "-b",
141
- # desc:
142
- # "Setup replication from target database to source database"
143
144
  def switchover
144
145
  PgEasyReplicate::Orchestrate.switchover(
145
146
  group_name: options[:group_name],
146
147
  lag_delta_size: options[:lag_delta_size],
147
- skip_vacuum_analyze: options[:skip_vacuum_analyze]
148
+ skip_vacuum_analyze: options[:skip_vacuum_analyze],
148
149
  )
149
150
  end
150
151
 
@@ -162,6 +163,71 @@ module PgEasyReplicate
162
163
  end
163
164
  end
164
165
 
166
+ desc "list_ddl_changes", "Lists recent DDL changes in the source database"
167
+ method_option :group_name,
168
+ aliases: "-g",
169
+ required: true,
170
+ desc: "Name of the group"
171
+ method_option :limit,
172
+ aliases: "-l",
173
+ type: :numeric,
174
+ default: 100,
175
+ desc: "Limit the number of DDL changes to display"
176
+ def list_ddl_changes
177
+ changes =
178
+ PgEasyReplicate::DDLManager.list_ddl_changes(
179
+ group_name: options[:group_name],
180
+ limit: options[:limit],
181
+ )
182
+ puts JSON.pretty_generate(changes)
183
+ end
184
+
185
+ desc "apply_ddl_change", "Applies DDL changes to the target database"
186
+ method_option :group_name,
187
+ aliases: "-g",
188
+ required: true,
189
+ desc: "Name of the group"
190
+ method_option :id,
191
+ aliases: "-i",
192
+ type: :numeric,
193
+ desc:
194
+ "ID of the specific DDL change to apply. If not provided, all changes will be applied."
195
+ def apply_ddl_change
196
+ if options[:id]
197
+ PgEasyReplicate::DDLManager.apply_ddl_change(
198
+ group_name: options[:group_name],
199
+ id: options[:id],
200
+ )
201
+ puts "DDL change with ID #{options[:id]} applied successfully."
202
+ else
203
+ changes =
204
+ PgEasyReplicate::DDLManager.list_ddl_changes(
205
+ group_name: options[:group_name],
206
+ )
207
+ if changes.empty?
208
+ puts "No pending DDL changes to apply."
209
+ return
210
+ end
211
+
212
+ puts "The following DDL changes will be applied:"
213
+ changes.each do |change|
214
+ puts "ID: #{change[:id]}, Type: #{change[:object_type]}, Command: #{change[:ddl_command]}"
215
+ end
216
+ puts ""
217
+ print("Do you want to apply all these changes? (y/n): ")
218
+ confirmation = $stdin.gets.chomp.downcase
219
+
220
+ if confirmation == "y"
221
+ PgEasyReplicate::DDLManager.apply_all_ddl_changes(
222
+ group_name: options[:group_name],
223
+ )
224
+ puts "All pending DDL changes applied successfully."
225
+ else
226
+ puts "Operation cancelled."
227
+ end
228
+ end
229
+ end
230
+
165
231
  desc "version", "Prints the version"
166
232
  def version
167
233
  puts PgEasyReplicate::VERSION
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg_query"
4
+
5
+ module PgEasyReplicate
6
+ class DDLAudit
7
+ extend Helper
8
+
9
+ class << self
10
+ def setup(group_name)
11
+ conn = connect_to_internal_schema
12
+ return if conn.table_exists?(table_name)
13
+
14
+ begin
15
+ conn.create_table(table_name) do
16
+ primary_key(:id)
17
+ String(:group_name, null: false)
18
+ String(:event_type, null: false)
19
+ String(:object_type)
20
+ String(:object_identity)
21
+ String(:ddl_command, text: true)
22
+ DateTime(:created_at, default: Sequel::CURRENT_TIMESTAMP)
23
+ end
24
+
25
+ create_trigger_function(conn, group_name)
26
+ create_event_triggers(conn, group_name)
27
+ rescue => e
28
+ abort_with("Failed to set up DDL audit: #{e.message}")
29
+ ensure
30
+ conn&.disconnect
31
+ end
32
+ end
33
+
34
+ def create(
35
+ group_name,
36
+ event_type,
37
+ object_type,
38
+ object_identity,
39
+ ddl_command
40
+ )
41
+ conn = connect_to_internal_schema
42
+ begin
43
+ conn[table_name].insert(
44
+ group_name: group_name,
45
+ event_type: event_type,
46
+ object_type: object_type,
47
+ object_identity: object_identity,
48
+ ddl_command: ddl_command,
49
+ created_at: Time.now.utc,
50
+ )
51
+ rescue => e
52
+ abort_with("Adding DDL audit entry failed: #{e.message}")
53
+ ensure
54
+ conn&.disconnect
55
+ end
56
+ end
57
+
58
+ def list_changes(group_name, limit: 100)
59
+ conn = connect_to_internal_schema
60
+ begin
61
+ conn[table_name]
62
+ .where(group_name: group_name)
63
+ .order(Sequel.desc(:id))
64
+ .limit(limit)
65
+ .all
66
+ rescue => e
67
+ abort_with("Listing DDL changes failed: #{e.message}")
68
+ ensure
69
+ conn&.disconnect
70
+ end
71
+ end
72
+
73
+ def apply_change(source_conn_string, target_conn_string, group_name, id)
74
+ ddl_queries = fetch_ddl_query(source_conn_string, group_name, id: id)
75
+ apply_ddl_changes(target_conn_string, ddl_queries)
76
+ end
77
+
78
+ def apply_all_changes(source_conn_string, target_conn_string, group_name)
79
+ ddl_queries = fetch_ddl_query(source_conn_string, group_name)
80
+ apply_ddl_changes(target_conn_string, ddl_queries)
81
+ end
82
+
83
+ def drop(group_name)
84
+ conn = connect_to_internal_schema
85
+ begin
86
+ drop_event_triggers(conn, group_name)
87
+ drop_trigger_function(conn, group_name)
88
+ conn[table_name].where(group_name: group_name).delete
89
+ rescue => e
90
+ abort_with("Dropping DDL audit failed: #{e.message}")
91
+ ensure
92
+ conn&.disconnect
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def table_name
99
+ :pger_ddl_audits
100
+ end
101
+
102
+ def connect_to_internal_schema(conn_string = nil)
103
+ Query.connect(
104
+ connection_url: conn_string || source_db_url,
105
+ schema: internal_schema_name,
106
+ )
107
+ end
108
+
109
+ def create_trigger_function(conn, group_name)
110
+ group = PgEasyReplicate::Group.find(group_name)
111
+ tables = group[:table_names].split(",").map(&:strip)
112
+ schema_name = group[:schema_name]
113
+ sanitized_group_name = sanitize_identifier(group_name)
114
+
115
+ full_table_names = tables.map { |table| "#{schema_name}.#{table}" }
116
+ table_pattern = full_table_names.join("|")
117
+
118
+ conn.run(<<~SQL)
119
+ CREATE OR REPLACE FUNCTION #{internal_schema_name}.pger_ddl_trigger_#{sanitized_group_name}() RETURNS event_trigger AS $$
120
+ DECLARE
121
+ obj record;
122
+ ddl_command text;
123
+ affected_table text;
124
+ BEGIN
125
+ SELECT current_query() INTO ddl_command;
126
+
127
+ IF TG_EVENT = 'ddl_command_end' THEN
128
+ FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands()
129
+ LOOP
130
+ IF obj.object_identity ~ '^(#{table_pattern})' THEN
131
+ INSERT INTO #{internal_schema_name}.#{table_name} (group_name, event_type, object_type, object_identity, ddl_command)
132
+ VALUES ('#{group_name}', TG_EVENT, obj.object_type, obj.object_identity, ddl_command);
133
+ ELSIF obj.object_type = 'index' THEN
134
+ SELECT (regexp_match(ddl_command, 'ON\\s+(\\S+)'))[1] INTO affected_table;
135
+ IF affected_table IN ('#{full_table_names.join("','")}') THEN
136
+ INSERT INTO #{internal_schema_name}.#{table_name} (group_name, event_type, object_type, object_identity, ddl_command)
137
+ VALUES ('#{group_name}', TG_EVENT, obj.object_type, obj.object_identity, ddl_command);
138
+ END IF;
139
+ END IF;
140
+ END LOOP;
141
+ ELSIF TG_EVENT = 'sql_drop' THEN
142
+ FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects()
143
+ LOOP
144
+ IF (obj.object_identity = ANY(ARRAY['#{full_table_names.join("','")}']) OR
145
+ obj.object_identity ~ ('^' || '#{schema_name}' || '\\.(.*?)_.*$'))
146
+ THEN
147
+ INSERT INTO #{internal_schema_name}.#{table_name} (group_name, event_type, object_type, object_identity, ddl_command)
148
+ VALUES ('#{group_name}', TG_EVENT, obj.object_type, obj.object_identity, ddl_command);
149
+ END IF;
150
+ END LOOP;
151
+ ELSIF TG_EVENT = 'table_rewrite' THEN
152
+ FOR obj IN SELECT * FROM pg_event_trigger_table_rewrite_oid()
153
+ LOOP
154
+ SELECT c.relname, n.nspname INTO affected_table
155
+ FROM pg_class c
156
+ JOIN pg_namespace n ON n.oid = c.relnamespace
157
+ WHERE c.oid = obj.oid;
158
+
159
+ IF affected_table IN ('#{full_table_names.join("','")}') THEN
160
+ INSERT INTO #{internal_schema_name}.#{table_name} (group_name, event_type, object_type, object_identity, ddl_command)
161
+ VALUES ('#{group_name}', TG_EVENT, 'table', affected_table, 'table_rewrite');
162
+ END IF;
163
+ END LOOP;
164
+ END IF;
165
+ END;
166
+ $$ LANGUAGE plpgsql;
167
+ SQL
168
+ rescue => e
169
+ abort_with("Creating DDL trigger function failed: #{e.message}")
170
+ end
171
+
172
+ def create_event_triggers(conn, group_name)
173
+ sanitized_group_name = sanitize_identifier(group_name)
174
+ conn.run(<<~SQL)
175
+ DROP EVENT TRIGGER IF EXISTS pger_ddl_trigger_#{sanitized_group_name};
176
+ CREATE EVENT TRIGGER pger_ddl_trigger_#{sanitized_group_name} ON ddl_command_end
177
+ EXECUTE FUNCTION #{internal_schema_name}.pger_ddl_trigger_#{sanitized_group_name}();
178
+
179
+ DROP EVENT TRIGGER IF EXISTS pger_drop_trigger_#{sanitized_group_name};
180
+ CREATE EVENT TRIGGER pger_drop_trigger_#{sanitized_group_name} ON sql_drop
181
+ EXECUTE FUNCTION #{internal_schema_name}.pger_ddl_trigger_#{sanitized_group_name}();
182
+
183
+ DROP EVENT TRIGGER IF EXISTS pger_table_rewrite_trigger_#{sanitized_group_name};
184
+ CREATE EVENT TRIGGER pger_table_rewrite_trigger_#{sanitized_group_name} ON table_rewrite
185
+ EXECUTE FUNCTION #{internal_schema_name}.pger_ddl_trigger_#{sanitized_group_name}();
186
+ SQL
187
+ rescue => e
188
+ abort_with("Creating event triggers failed: #{e.message}")
189
+ end
190
+
191
+ def drop_event_triggers(conn, group_name)
192
+ sanitized_group_name = sanitize_identifier(group_name)
193
+ conn.run(<<~SQL)
194
+ DROP EVENT TRIGGER IF EXISTS pger_ddl_trigger_#{sanitized_group_name};
195
+ DROP EVENT TRIGGER IF EXISTS pger_drop_trigger_#{sanitized_group_name};
196
+ DROP EVENT TRIGGER IF EXISTS pger_table_rewrite_trigger_#{sanitized_group_name};
197
+ SQL
198
+ rescue => e
199
+ abort_with("Dropping event triggers failed: #{e.message}")
200
+ end
201
+
202
+ def drop_trigger_function(conn, group_name)
203
+ sanitized_group_name = sanitize_identifier(group_name)
204
+ conn.run(
205
+ "DROP FUNCTION IF EXISTS #{internal_schema_name}.pger_ddl_trigger_#{sanitized_group_name}();",
206
+ )
207
+ rescue => e
208
+ abort_with("Dropping trigger function failed: #{e.message}")
209
+ end
210
+
211
+ def self.extract_table_info(sql)
212
+ parsed = PgQuery.parse(sql)
213
+ stmt = parsed.tree.stmts.first.stmt
214
+
215
+ case stmt
216
+ when PgQuery::CreateStmt, PgQuery::IndexStmt, PgQuery::AlterTableStmt
217
+ schema_name = stmt.relation.schemaname || "public"
218
+ table_name = stmt.relation.relname
219
+ "#{schema_name}.#{table_name}"
220
+ end
221
+ rescue PgQuery::ParseError
222
+ nil
223
+ end
224
+
225
+ def sanitize_identifier(identifier)
226
+ identifier.gsub(/[^a-zA-Z0-9_]/, "_")
227
+ end
228
+
229
+ def fetch_ddl_query(source_conn_string, group_name, id: nil)
230
+ source_conn = connect_to_internal_schema(source_conn_string)
231
+ begin
232
+ query = source_conn[table_name].where(group_name: group_name)
233
+ query = query.where(id: id) if id
234
+ result = query.order(:id).select_map(:ddl_command)
235
+ result.uniq
236
+ rescue => e
237
+ abort_with("Fetching DDL queries failed: #{e.message}")
238
+ ensure
239
+ source_conn&.disconnect
240
+ end
241
+ end
242
+
243
+ def apply_ddl_changes(target_conn_string, ddl_queries)
244
+ target_conn = Query.connect(connection_url: target_conn_string)
245
+ begin
246
+ ddl_queries.each do |query|
247
+ target_conn.run(query)
248
+ rescue => e
249
+ abort_with(
250
+ "Error executing DDL command: #{query}. Error: #{e.message}",
251
+ )
252
+ end
253
+ rescue => e
254
+ abort_with("Applying DDL changes failed: #{e.message}")
255
+ ensure
256
+ target_conn&.disconnect
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEasyReplicate
4
+ module DDLManager
5
+ extend Helper
6
+
7
+ class << self
8
+ def setup_ddl_tracking(
9
+ group_name:, conn_string: source_db_url,
10
+ schema: "public"
11
+ )
12
+ DDLAudit.setup(group_name)
13
+ end
14
+
15
+ def cleanup_ddl_tracking(
16
+ group_name:, conn_string: source_db_url,
17
+ schema: "public"
18
+ )
19
+ DDLAudit.drop(group_name)
20
+ end
21
+
22
+ def list_ddl_changes(
23
+ group_name:, conn_string: source_db_url,
24
+ schema: "public",
25
+ limit: 100
26
+ )
27
+ DDLAudit.list_changes(group_name, limit: limit)
28
+ end
29
+
30
+ def apply_ddl_change(
31
+ group_name:, id:, source_conn_string: source_db_url,
32
+ target_conn_string: target_db_url,
33
+ schema: "public"
34
+ )
35
+ DDLAudit.apply_change(
36
+ source_conn_string,
37
+ target_conn_string,
38
+ group_name,
39
+ id,
40
+ )
41
+ end
42
+
43
+ def apply_all_ddl_changes(
44
+ group_name:, source_conn_string: source_db_url,
45
+ target_conn_string: target_db_url,
46
+ schema: "public"
47
+ )
48
+ DDLAudit.apply_all_changes(
49
+ source_conn_string,
50
+ target_conn_string,
51
+ group_name,
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -15,7 +15,7 @@ module PgEasyReplicate
15
15
  schema: schema_name,
16
16
  conn_string: source_db_url,
17
17
  list: options[:tables],
18
- exclude_list: options[:exclude_tables]
18
+ exclude_list: options[:exclude_tables],
19
19
  )
20
20
 
21
21
  if options[:recreate_indices_post_copy]
@@ -52,25 +52,19 @@ module PgEasyReplicate
52
52
  started_at: Time.now.utc,
53
53
  recreate_indices_post_copy: options[:recreate_indices_post_copy],
54
54
  )
55
- rescue => e
56
- stop_sync(
57
- group_name: options[:group_name],
58
- source_conn_string: source_db_url,
59
- target_conn_string: target_db_url,
60
- )
61
55
 
62
- if Group.find(options[:group_name])
63
- Group.update(group_name: options[:group_name], failed_at: Time.now)
64
- else
65
- Group.create(
66
- name: options[:group_name],
67
- table_names: tables.join(","),
68
- schema_name: schema_name,
69
- started_at: Time.now.utc,
70
- failed_at: Time.now.utc,
56
+ if options[:track_ddl]
57
+ DDLManager.setup_ddl_tracking(
58
+ conn_string: source_db_url,
59
+ group_name: options[:group_name],
60
+ schema: schema_name,
71
61
  )
72
62
  end
73
-
63
+ rescue => e
64
+ stop_sync(group_name: options[:group_name])
65
+ if Group.find(options[:group_name])
66
+ Group.update(name: options[:group_name], failed_at: Time.now.utc)
67
+ end
74
68
  abort_with("Starting sync failed: #{e.message}")
75
69
  end
76
70
 
@@ -180,8 +174,8 @@ module PgEasyReplicate
180
174
 
181
175
  def stop_sync(
182
176
  group_name:,
183
- source_conn_string: source_db_url,
184
- target_conn_string: target_db_url
177
+ source_conn_string: nil,
178
+ target_conn_string: nil
185
179
  )
186
180
  logger.info(
187
181
  "Stopping sync",
@@ -192,29 +186,34 @@ module PgEasyReplicate
192
186
  )
193
187
  drop_publication(
194
188
  group_name: group_name,
195
- conn_string: source_conn_string,
189
+ conn_string: source_conn_string || source_db_url,
196
190
  )
197
191
  drop_subscription(
198
192
  group_name: group_name,
199
- target_conn_string: target_conn_string,
193
+ target_conn_string: target_conn_string || target_db_url,
200
194
  )
201
195
  rescue => e
202
- raise "Unable to stop sync user: #{e.message}"
196
+ abort_with("Unable to stop sync: #{e.message}")
203
197
  end
204
198
 
205
199
  def switchover(
206
200
  group_name:,
207
- source_conn_string: source_db_url,
208
- target_conn_string: target_db_url,
209
201
  lag_delta_size: nil,
210
- skip_vacuum_analyze: false
202
+ skip_vacuum_analyze: false,
203
+ source_conn_string: nil,
204
+ target_conn_string: nil
211
205
  )
212
206
  group = Group.find(group_name)
207
+ abort_with("Group not found: #{group_name}") unless group
208
+
213
209
  tables_list = group[:table_names].split(",")
214
210
 
211
+ source_conn = source_conn_string || source_db_url
212
+ target_conn = target_conn_string || target_db_url
213
+
215
214
  unless skip_vacuum_analyze
216
215
  run_vacuum_analyze(
217
- conn_string: target_conn_string,
216
+ conn_string: target_conn,
218
217
  tables: tables_list,
219
218
  schema: group[:schema_name],
220
219
  )
@@ -225,39 +224,35 @@ module PgEasyReplicate
225
224
  if group[:recreate_indices_post_copy]
226
225
  IndexManager.wait_for_replication_completion(group_name: group_name)
227
226
  IndexManager.recreate_indices(
228
- source_conn_string: source_db_url,
229
- target_conn_string: target_db_url,
227
+ source_conn_string: source_conn,
228
+ target_conn_string: target_conn,
230
229
  tables: tables_list,
231
230
  schema: group[:schema_name],
232
231
  )
233
232
  end
234
233
 
235
- # Watch for lag again, because it could've grown during index recreation
236
234
  watch_lag(group_name: group_name, lag: lag_delta_size || DEFAULT_LAG)
237
235
 
238
236
  revoke_connections_on_source_db(group_name)
239
237
  wait_for_remaining_catchup(group_name)
240
- refresh_sequences(
241
- conn_string: target_conn_string,
242
- schema: group[:schema_name],
243
- )
238
+ refresh_sequences(conn_string: target_conn, schema: group[:schema_name])
244
239
  mark_switchover_complete(group_name)
245
- # Run vacuum analyze to refresh the planner post switchover
240
+
246
241
  unless skip_vacuum_analyze
247
242
  run_vacuum_analyze(
248
- conn_string: target_conn_string,
243
+ conn_string: target_conn,
249
244
  tables: tables_list,
250
245
  schema: group[:schema_name],
251
246
  )
252
247
  end
248
+
253
249
  drop_subscription(
254
250
  group_name: group_name,
255
- target_conn_string: target_conn_string,
251
+ target_conn_string: target_conn,
256
252
  )
257
253
  rescue => e
258
254
  restore_connections_on_source_db(group_name)
259
-
260
- abort_with("Switchover sync failed: #{e.message}")
255
+ abort_with("Switchover failed: #{e.message}")
261
256
  end
262
257
 
263
258
  def watch_lag(group_name:, wait_time: DEFAULT_WAIT, lag: DEFAULT_LAG)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgEasyReplicate
4
- VERSION = "0.2.7"
4
+ VERSION = "0.3.1"
5
5
  end
@@ -6,6 +6,7 @@ require "pg"
6
6
  require "sequel"
7
7
  require "open3"
8
8
  require "English"
9
+ require "pg_query"
9
10
 
10
11
  require "pg_easy_replicate/helper"
11
12
  require "pg_easy_replicate/version"
@@ -15,6 +16,8 @@ require "pg_easy_replicate/orchestrate"
15
16
  require "pg_easy_replicate/stats"
16
17
  require "pg_easy_replicate/group"
17
18
  require "pg_easy_replicate/cli"
19
+ require "pg_easy_replicate/ddl_audit"
20
+ require "pg_easy_replicate/ddl_manager"
18
21
 
19
22
  Sequel.default_timezone = :utc
20
23
  module PgEasyReplicate
@@ -200,6 +203,14 @@ module PgEasyReplicate
200
203
  logger.info("Dropping replication user on target database")
201
204
  drop_user(conn_string: target_db_url)
202
205
  end
206
+ -> do
207
+ if options[:everything]
208
+ PgEasyReplicate::DDLManager.cleanup_ddl_tracking(
209
+ conn_string: source_db_url,
210
+ group_name: options[:group_name],
211
+ )
212
+ end
213
+ end
203
214
  end,
204
215
  ]
205
216
 
data/scripts/e2e-start.sh CHANGED
@@ -10,9 +10,17 @@ export SOURCE_DB_URL="postgres://james-bond:james-bond123%407%21%273aaR@localhos
10
10
  export TARGET_DB_URL="postgres://james-bond:james-bond123%407%21%273aaR@localhost:5433/postgres-db"
11
11
  export PGPASSWORD='james-bond123@7!'"'"''"'"'3aaR'
12
12
 
13
- # Config check, Bootstrap and cleanup
14
13
  echo "===== Performing Bootstrap and cleanup"
15
14
  bundle exec bin/pg_easy_replicate bootstrap -g cluster-1 --copy-schema
16
- bundle exec bin/pg_easy_replicate start_sync -g cluster-1 -s public --recreate-indices-post-copy
15
+ bundle exec bin/pg_easy_replicate start_sync -g cluster-1 -s public --recreate-indices-post-copy --track-ddl
17
16
  bundle exec bin/pg_easy_replicate stats -g cluster-1
17
+
18
+ echo "===== Applying DDL change"
19
+ psql $SOURCE_DB_URL -c "ALTER TABLE public.pgbench_accounts ADD COLUMN test_column VARCHAR(255)"
20
+
21
+ echo "===== Applying DDL changes"
22
+ echo "Y" | bundle exec bin/pg_easy_replicate apply_ddl_change -g cluster-1
23
+
24
+ # Switchover
25
+ echo "===== Performing switchover"
18
26
  bundle exec bin/pg_easy_replicate switchover -g cluster-1
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_easy_replicate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.7
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shayon Mukherjee
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-08-09 00:00:00.000000000 Z
11
+ date: 2024-08-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ougai
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: 1.5.3
41
+ - !ruby/object:Gem::Dependency
42
+ name: pg_query
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 5.1.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 5.1.0
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: sequel
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -47,7 +61,7 @@ dependencies:
47
61
  version: '5.69'
48
62
  - - "<"
49
63
  - !ruby/object:Gem::Version
50
- version: '5.83'
64
+ version: '5.84'
51
65
  type: :runtime
52
66
  prerelease: false
53
67
  version_requirements: !ruby/object:Gem::Requirement
@@ -57,7 +71,7 @@ dependencies:
57
71
  version: '5.69'
58
72
  - - "<"
59
73
  - !ruby/object:Gem::Version
60
- version: '5.83'
74
+ version: '5.84'
61
75
  - !ruby/object:Gem::Dependency
62
76
  name: thor
63
77
  requirement: !ruby/object:Gem::Requirement
@@ -274,6 +288,8 @@ files:
274
288
  - docker-compose.yml
275
289
  - lib/pg_easy_replicate.rb
276
290
  - lib/pg_easy_replicate/cli.rb
291
+ - lib/pg_easy_replicate/ddl_audit.rb
292
+ - lib/pg_easy_replicate/ddl_manager.rb
277
293
  - lib/pg_easy_replicate/group.rb
278
294
  - lib/pg_easy_replicate/helper.rb
279
295
  - lib/pg_easy_replicate/index_manager.rb