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:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d85998326734611fffa7a261b4da9e7aaa23cb0fc8e460c3e2e10206e8c2012d
|
4
|
+
data.tar.gz: 375f48b3a024bdcedb8a10c2752c60ea8d4acc5305dc0123d6ab993f6da5d185
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
8
|
-
|
9
|
-
|
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
|
-
|
17
|
-
|
18
|
-
|
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
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
66
|
-
|
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
|
-
|
71
|
-
|
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
|
-
|
77
|
-
|
78
|
-
|
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
|
-
|
82
|
-
|
83
|
-
|
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
|
-
|
90
|
-
|
91
|
-
|
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
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
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
|
-
|
126
|
-
|
127
|
-
@target_rep_conn.exec("TRUNCATE TABLE #{table}")
|
128
|
-
end
|
164
|
+
def create_subscription
|
165
|
+
@log.info("Creating subscription #{@slot}")
|
129
166
|
|
130
|
-
|
131
|
-
|
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
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
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
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
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
|
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.
|
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-
|
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.
|
121
|
+
rubygems_version: 3.4.10
|
122
122
|
signing_key:
|
123
123
|
specification_version: 4
|
124
124
|
summary: PostgreSQL logical replication setup tool
|