pg_logical_replicator 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7db48910687f53995bb0481317730468d28b8d27c250c7e61434a462609c437
4
- data.tar.gz: d41e788e6748bbf60eaf07ea45abf2a493d5ba6ad668562de06be582ff94a073
3
+ metadata.gz: d85998326734611fffa7a261b4da9e7aaa23cb0fc8e460c3e2e10206e8c2012d
4
+ data.tar.gz: 375f48b3a024bdcedb8a10c2752c60ea8d4acc5305dc0123d6ab993f6da5d185
5
5
  SHA512:
6
- metadata.gz: 52dfcf1355d45c6b9321b73dbaedea50d2d71175230063caf17a408a52356833372bba9dad7fb66f35b6548a7417ab3602fb5035ffd5dfff37c7e425124c6dc6
7
- data.tar.gz: 9d3e54057f4636619a62542072a4455a5be60a4fe1872f5ccbcdc339daf8b66fef3df08cc4a34c2dfcb8d9b805d5aa6f9d06185d5150b5aaeef25f73af884185
6
+ metadata.gz: 387f5aa4609c1d69f11eb996fe4ee64890178ced6b7fb9a1f53eaad0233f9050c941362dc51c1b73e7d1133bef7058898ad1375b6550553048a011d224a27eb9
7
+ data.tar.gz: a3a0fe506552ecd598d4a31f7958d24ea6f257fe3e073136143b3252ea24a16c57d7e517577a7221c6e994704a4b4947c58fb7ad6b1de979fd36e6906348cd43
@@ -19,7 +19,7 @@ module PgLogicalReplicator
19
19
  method_option :target_rep_password, type: :string
20
20
  method_option :num_slots, type: :numeric, default: 10
21
21
  method_option :groups, type: :array
22
-
22
+
23
23
  def setup
24
24
  source_host = options[:source_host]
25
25
  source_port = options[:source_port]
@@ -35,11 +35,11 @@ module PgLogicalReplicator
35
35
  target_rep_password = options[:target_rep_password] || target_password
36
36
  num_slots = options[:num_slots]
37
37
  target_groups = options[:groups].map(&:to_i) if options[:groups]
38
-
38
+
39
39
  conn = PG.connect(dbname: source_database, user: source_username, password: source_password, host: source_host, port: source_port)
40
-
40
+
41
41
  query = "SELECT tablename FROM pg_tables WHERE schemaname = 'public' order by tablename ASC;"
42
-
42
+
43
43
  tables = begin
44
44
  result = conn.exec(query)
45
45
  result.map { |row| row['tablename'] }
@@ -47,31 +47,33 @@ module PgLogicalReplicator
47
47
  conn.close if conn
48
48
  end
49
49
  puts "Total Tables: #{tables.size}"
50
-
50
+
51
51
  tables_per_group = tables.size / num_slots
52
52
  puts "Tables per group: #{tables_per_group}"
53
-
53
+
54
54
  dump_dir_root = "~/db-dumps-#{Time.now.strftime('%Y%m%d%H%M%S')}"
55
55
  system("mkdir -p #{dump_dir_root}")
56
-
56
+
57
57
  puts "Target Groups: #{target_groups}"
58
-
58
+
59
59
  tables.each_slice(tables_per_group).with_index do |table_group, group_idx|
60
60
  puts "Processing group #{group_idx + 1} size: #{table_group.size}"
61
-
61
+
62
62
  group_number = group_idx + 1
63
-
63
+
64
64
  next unless target_groups.nil? || target_groups.include?(group_number)
65
-
65
+
66
66
  table_names = table_group.join(',')
67
-
67
+
68
68
  dump_dir = "#{dump_dir_root}/#{group_number}"
69
-
69
+
70
70
  puts "Removing directory: #{dump_dir}"
71
-
71
+
72
72
  FileUtils.rm_rf(dump_dir)
73
+
74
+ puts "Creating directory: #{dump_dir}"
73
75
  FileUtils.mkdir_p(dump_dir)
74
-
76
+
75
77
  LogicalReplicationInitializer.new({
76
78
  slot_name: "logical_sub_grp_#{group_number}",
77
79
  publication_name: "logical_pub_grp_#{group_number}",
@@ -81,9 +83,13 @@ module PgLogicalReplicator
81
83
  table_names: table_names,
82
84
  dump_dir: dump_dir
83
85
  }).start
86
+ rescue StandardError => e
87
+ puts "Error: #{e.message}"
88
+ ensure
89
+ FileUtils.rm_rf(dump_dir)
84
90
  end
85
91
  end
86
-
92
+
87
93
  desc 'stop_replication', 'Stop all replication'
88
94
  method_option :source_host, type: :string, required: true
89
95
  method_option :source_port, type: :numeric, default: 5432
@@ -95,7 +101,7 @@ module PgLogicalReplicator
95
101
  method_option :source_password, type: :string, required: true
96
102
  method_option :target_username, type: :string
97
103
  method_option :target_password, type: :string
98
-
104
+
99
105
  def stop_replication
100
106
  source_config = {
101
107
  host: options[:source_host],
@@ -104,7 +110,7 @@ module PgLogicalReplicator
104
110
  user: options[:source_username],
105
111
  password: options[:source_password]
106
112
  }
107
-
113
+
108
114
  target_config = {
109
115
  host: options[:target_host],
110
116
  port: options[:target_port],
@@ -112,10 +118,10 @@ module PgLogicalReplicator
112
118
  user: options[:target_username] || options[:source_username],
113
119
  password: options[:target_password] || options[:source_password]
114
120
  }
115
-
121
+
116
122
  ReplicationStopper.new(source_config, target_config).stop_replication
117
123
  end
118
-
124
+
119
125
  desc 'transfer_schema', 'Transfer schema from source to target database'
120
126
  method_option :source_host, type: :string, required: true
121
127
  method_option :source_port, type: :numeric, default: 5432
@@ -127,7 +133,7 @@ module PgLogicalReplicator
127
133
  method_option :source_password, type: :string, required: true
128
134
  method_option :target_username, type: :string, required: true
129
135
  method_option :target_password, type: :string, required: true
130
-
136
+
131
137
  def transfer_schema
132
138
  SchemaTransfer.new(
133
139
  source_host: options[:source_host],
@@ -1,145 +1,206 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
1
4
  require 'optparse'
2
- require 'pg'
3
5
  require 'logger'
6
+ require 'pg'
4
7
  require 'fileutils'
5
8
  require 'open3'
6
9
 
7
- module PgLogicalReplicator
8
- class LogicalReplicationInitializer
9
- def initialize(args)
10
- @options = args
11
- setup_logger
12
- validate_options
13
- connect_to_databases
14
- end
10
+ class LogicalReplicationInitializer
11
+
12
+ attr :options
15
13
 
16
- def setup_logger
17
- @log = Logger.new(STDOUT)
18
- @log.level = Logger::INFO
14
+ def initialize(args)
15
+ @options = args
16
+
17
+ setup_logger
18
+ validate_options
19
+ connect_to_databases
20
+ end
21
+
22
+ def setup_logger
23
+ @log = Logger.new(STDOUT)
24
+ @log.level = Logger::INFO
25
+ end
26
+
27
+ def validate_options
28
+ [:primary_conn_str, :target_conn_str, :target_rep_conn_str, :table_names, :dump_dir].each do |opt|
29
+ raise OptionParser::MissingArgument, opt.to_s unless @options[opt]
19
30
  end
20
31
 
21
- def validate_options
22
- [:primary_conn_str, :target_conn_str, :target_rep_conn_str, :table_names, :dump_dir].each do |opt|
23
- raise OptionParser::MissingArgument, opt.to_s unless @options[opt]
32
+ @primary_conn_str = options[:primary_conn_str] || (raise OptionParser::MissingArgument, '--primary-conn-str is missing')
33
+ @secondary_conn_str = options[:secondary_conn_str] || @primary_conn_str
34
+ @target_conn_str = options[:target_conn_str] || (raise OptionParser::MissingArgument, '--target-conn-str is missing')
35
+ @target_rep_conn_str = options[:target_rep_conn_str] || (raise OptionParser::MissingArgument, '--target-rep-conn-str is missing')
36
+ @table_names = options[:table_names] || (raise OptionParser::MissingArgument, '--table-names is missing')
37
+ @subscription_conn_str = options[:subscription_conn_str] || @primary_conn_str
38
+ @dump_dir = options[:dump_dir] || (raise OptionParser::MissingArgument, '--dump-dir is missing')
39
+ @slot = options[:slot_name]
40
+ @publication_name = options[:publication_name]
41
+ end
42
+
43
+ def start
44
+ begin
45
+ @log.info("Dumping to #{@dump_dir}")
46
+ connect_to_databases
47
+
48
+ drop_subscription
49
+ drop_publication
50
+ create_publication
51
+ create_logical_replication_slot
52
+
53
+ lsn = with_snapshot do |lsn, snapshot|
54
+ @log.info("Creating Snapshot with lsn #{lsn}")
55
+
56
+ dump(snapshot)
57
+
58
+ lsn
24
59
  end
25
60
 
26
- @options[:slot_name] ||= 'migration_sub'
27
- @options[:publication_name] ||= 'migration_pub'
28
- @options[:secondary_conn_str] ||= @options[:primary_conn_str]
29
- @options[:subscription_conn_str] ||= @options[:primary_conn_str]
61
+ restore_dump
62
+ create_subscription
63
+ advance_subscription_origin(lsn)
64
+ enable_subscription
65
+ rescue StandardError => e
66
+ @log.error(e.message)
67
+ exit 1
30
68
  end
69
+ end
31
70
 
32
- def connect_to_databases
33
- @primary_conn = PG.connect(@options[:primary_conn_str])
34
- @secondary_conn = PG.connect(@options[:secondary_conn_str])
35
- @target_conn = PG.connect(@options[:target_conn_str])
36
- @target_rep_conn = PG.connect(@options[:target_rep_conn_str])
37
- end
71
+ def connect_to_databases
72
+ @primary = PG.connect(@primary_conn_str)
73
+ @secondary = PG.connect(@secondary_conn_str)
74
+ @target = PG.connect(@target_conn_str)
75
+ @target_rep = PG.connect(@target_rep_conn_str)
76
+ end
38
77
 
39
- def start
40
- begin
41
- @log.info("Dumping to #{@options[:dump_dir]}")
42
-
43
- drop_subscription
44
- drop_publication
45
- create_publication
46
- create_logical_replication_slot
47
-
48
- snapshot = create_snapshot
49
- dump_database(snapshot)
50
- restore_dump
51
- create_subscription
52
- advance_subscription_origin(snapshot[:lsn])
53
- enable_subscription
54
- rescue => e
55
- @log.error(e.message)
56
- exit 1
78
+ def create_publication
79
+ @primary.exec("SELECT COUNT(*) FROM pg_publication WHERE pubname = $1", [@publication_name]) do |res|
80
+ cnt = res.getvalue(0, 0).to_i
81
+
82
+ if cnt.zero?
83
+ pub_name = PG::Connection.quote_ident(@publication_name)
84
+ table_list = @table_names.split(',')
85
+
86
+ @primary.exec("CREATE PUBLICATION #{pub_name} FOR TABLE #{PG::Connection.quote_ident(table_list[0])}")
87
+
88
+ table_list[1..].each do |table|
89
+ begin
90
+ @log.info("Processing table: #{table}")
91
+ @primary.exec("ALTER PUBLICATION #{pub_name} ADD TABLE #{PG::Connection.quote_ident(table)}")
92
+ rescue StandardError => e
93
+ @log.error("Error adding table #{table}: #{e.message}")
94
+ end
95
+ end
96
+ @log.info("Created publication #{@publication_name}")
97
+ else
98
+ @log.info("Publication #{@publication_name} already exists")
57
99
  end
58
100
  end
101
+ end
59
102
 
60
- def drop_subscription
61
- @log.info("Dropping subscription #{@options[:slot_name]}")
62
- @target_conn.exec("DROP SUBSCRIPTION IF EXISTS #{@options[:slot_name]}")
103
+ def create_logical_replication_slot
104
+ @primary.exec("SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1", [@slot])
105
+ @primary.exec("SELECT pg_create_logical_replication_slot($1, $2)", [@slot, "pgoutput"]) do |res|
106
+ slot_name = res.getvalue(0, 0)
107
+ @log.info("Created logical replication slot #{slot_name}")
63
108
  end
109
+ end
64
110
 
65
- def drop_publication
66
- @log.info("Dropping publication #{@options[:publication_name]}")
67
- @primary_conn.exec("DROP PUBLICATION IF EXISTS #{@options[:publication_name]}")
68
- end
111
+ def with_snapshot
112
+ @secondary.exec("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ")
69
113
 
70
- def create_publication
71
- @log.info("Creating publication #{@options[:publication_name]}")
72
- table_names = @options[:table_names].split(',')
73
- @primary_conn.exec("CREATE PUBLICATION #{@options[:publication_name]} FOR TABLE #{table_names.join(', ')}")
114
+ @secondary.exec("SELECT CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_lsn() END, pg_export_snapshot()") do |res|
115
+ yield res.getvalue(0, 0), res.getvalue(0, 1)
74
116
  end
117
+ end
75
118
 
76
- def create_logical_replication_slot
77
- @log.info("Creating logical replication slot #{@options[:slot_name]}")
78
- @primary_conn.exec("SELECT pg_create_logical_replication_slot('#{@options[:slot_name]}', 'pgoutput')")
119
+ def dump(snapshot)
120
+ @log.info("Dumping source DB")
121
+ table_list = @table_names.split(',')
122
+
123
+ cmd = [
124
+ 'pg_dump',
125
+ '--no-publication',
126
+ '--no-subscription',
127
+ "--snapshot=#{snapshot}",
128
+ '--format=d',
129
+ '--data-only',
130
+ '--jobs=8',
131
+ '-f', @dump_dir,
132
+ @secondary_conn_str
133
+ ]
134
+
135
+ table_list.each do |table|
136
+ @log.info("Dumping table #{table}")
137
+ cmd << '-t' << table
138
+ end
139
+
140
+ @log.info("Executing DB Dump Command: #{cmd.join(' ')}")
141
+ stdout, stderr, status = Open3.capture3(*cmd)
142
+ unless status.success?
143
+ @log.error("Error in pg_dump: #{stderr}")
144
+ raise "pg_dump failed"
79
145
  end
146
+ end
80
147
 
81
- def create_snapshot
82
- @log.info("Creating snapshot")
83
- @secondary_conn.transaction do |conn|
84
- res = conn.exec("SELECT pg_current_wal_lsn(), pg_export_snapshot()")
85
- { lsn: res.getvalue(0, 0), snapshot: res.getvalue(0, 1) }
86
- end
87
- end
148
+ def truncate_table(table_name)
149
+ @target.exec("TRUNCATE TABLE #{PG::Connection.quote_ident(table_name)}")
150
+ end
88
151
 
89
- def dump_database(snapshot)
90
- @log.info("Dumping source DB")
91
- table_names = @options[:table_names].split(',')
92
- dump_command = [
93
- 'pg_dump',
94
- '--no-publication',
95
- '--no-subscription',
96
- "--snapshot=#{snapshot[:snapshot]}",
97
- '--format=d',
98
- '--data-only',
99
- '--jobs=8',
100
- '-f', @options[:dump_dir],
101
- @options[:secondary_conn_str]
102
- ] + table_names.flat_map { |table| ['-t', table] }
103
-
104
- @log.info("Executing DB Dump Command: #{dump_command.join(' ')}")
105
- system(*dump_command)
106
- end
152
+ def restore_dump
153
+ table_names = @options[:table_names].split(',')
154
+ table_names.each { |table| truncate_table(table) }
107
155
 
108
- def restore_dump
109
- @log.info("Restoring dump to target DB")
110
- table_names = @options[:table_names].split(',')
111
- table_names.each { |table| truncate_table(table) }
112
-
113
- restore_command = [
114
- 'pg_restore',
115
- '--format=d',
116
- '--jobs=8',
117
- '--data-only',
118
- '-d', @options[:target_rep_conn_str],
119
- @options[:dump_dir]
120
- ]
121
-
122
- system(*restore_command)
156
+ @log.info("Restoring dump to target DB")
157
+ stdout, stderr, status = Open3.capture3('pg_restore', '--format=d', '--jobs=8', '--data-only', '-d', @target_rep_conn_str, @dump_dir)
158
+ unless status.success?
159
+ @log.error("Error in pg_restore: #{stderr}")
160
+ raise "pg_restore failed"
123
161
  end
162
+ end
124
163
 
125
- def truncate_table(table)
126
- @log.info("Truncating table #{table}")
127
- @target_rep_conn.exec("TRUNCATE TABLE #{table}")
128
- end
164
+ def create_subscription
165
+ @log.info("Creating subscription #{@slot}")
129
166
 
130
- def create_subscription
131
- @log.info("Creating subscription #{@options[:slot_name]}")
132
- @target_conn.exec("CREATE SUBSCRIPTION #{@options[:slot_name]} CONNECTION '#{@options[:subscription_conn_str]}' PUBLICATION #{@options[:publication_name]} WITH (create_slot=false, slot_name='#{@options[:slot_name]}', enabled=false, copy_data=false)")
133
- end
167
+ pub_name = PG::Connection.quote_ident(@publication_name)
168
+ sub_name = PG::Connection.quote_ident(@slot)
134
169
 
135
- def advance_subscription_origin(lsn)
136
- @log.info("Setting replication origin position to #{lsn}")
137
- @target_conn.exec("SELECT pg_replication_origin_advance('pg_' || subid::text, '#{lsn}') FROM pg_stat_subscription WHERE subname = '#{@options[:slot_name]}'")
138
- end
170
+ query = <<~SQL
171
+ CREATE SUBSCRIPTION #{sub_name}
172
+ CONNECTION '#{@subscription_conn_str}'
173
+ PUBLICATION #{pub_name}
174
+ WITH (create_slot=false, slot_name=#{sub_name}, enabled=false, copy_data=false)
175
+ SQL
139
176
 
140
- def enable_subscription
141
- @log.info("Enabling subscription #{@options[:slot_name]}")
142
- @target_conn.exec("ALTER SUBSCRIPTION #{@options[:slot_name]} ENABLE")
143
- end
177
+ @target.exec(query)
178
+ end
179
+
180
+ def advance_subscription_origin(lsn)
181
+ @log.info("Setting replication origin position to #{lsn}")
182
+
183
+ query = <<~SQL
184
+ SELECT pg_replication_origin_advance('pg_' || subid::text, '#{lsn}')
185
+ FROM pg_stat_subscription
186
+ WHERE subname = '#{@slot}'
187
+ SQL
188
+
189
+ @target.exec(query)
190
+ end
191
+
192
+ def enable_subscription
193
+ @log.info("Enabling subscription #{@slot}")
194
+ @target.exec("ALTER SUBSCRIPTION #{PG::Connection.quote_ident(@slot)} ENABLE")
195
+ end
196
+
197
+ def drop_publication
198
+ @log.info("Dropping publication #{@publication_name}")
199
+ @primary.exec("DROP PUBLICATION IF EXISTS #{PG::Connection.quote_ident(@publication_name)}")
200
+ end
201
+
202
+ def drop_subscription
203
+ @log.info("Dropping subscription #{@slot}")
204
+ @target.exec("DROP SUBSCRIPTION IF EXISTS #{PG::Connection.quote_ident(@slot)}")
144
205
  end
145
- end
206
+ end
@@ -1,3 +1,3 @@
1
1
  module PgLogicalReplicator
2
- VERSION = "0.1.3"
2
+ VERSION = "0.1.6"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_logical_replicator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - eni9889
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-07-14 00:00:00.000000000 Z
11
+ date: 2024-07-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg
@@ -118,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
118
118
  - !ruby/object:Gem::Version
119
119
  version: '0'
120
120
  requirements: []
121
- rubygems_version: 3.0.3.1
121
+ rubygems_version: 3.4.10
122
122
  signing_key:
123
123
  specification_version: 4
124
124
  summary: PostgreSQL logical replication setup tool