pg_logical_replicator 0.1.2 → 0.1.5
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: 2aab5bea887e74a1be5a02da58eb98b525c7ce3c786c7943136332f1b953c203
|
4
|
+
data.tar.gz: 201bed281909128a3bf8569360eb30f2d8d85601d9696207fd31919df5a45155
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0d41c1e2e0e75e51ed0dccaeffd47efdb6fd7900c720ab0203e9ac7a2c0247b3d73071b3ae91fff3d29b4b9fd3b32b1598cf7be3dfa3c59248ee75c4e685f4a7
|
7
|
+
data.tar.gz: 9bfa6c1923a933b4ac6d5c8c094da0de2a2230cea61b4d1957f38ecfc9ea8916afe1a4619e32f8abe4711ecd165dfdc833007546644397440a0656f04c1aba36
|
@@ -71,6 +71,9 @@ module PgLogicalReplicator
|
|
71
71
|
|
72
72
|
FileUtils.rm_rf(dump_dir)
|
73
73
|
|
74
|
+
puts "Creating directory: #{dump_dir}"
|
75
|
+
FileUtils.mkdir_p(dump_dir)
|
76
|
+
|
74
77
|
LogicalReplicationInitializer.new({
|
75
78
|
slot_name: "logical_sub_grp_#{group_number}",
|
76
79
|
publication_name: "logical_pub_grp_#{group_number}",
|
@@ -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.5
|
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
|