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: 852eb5d62a8dc6fffbb614962fe51b7566861273c52726bf546c20c74844b6e4
4
- data.tar.gz: a266b7f03ee0e9ceda86c9acaeb50d4d03d00c75b40a05fb23b73f6adef02d60
3
+ metadata.gz: 2aab5bea887e74a1be5a02da58eb98b525c7ce3c786c7943136332f1b953c203
4
+ data.tar.gz: 201bed281909128a3bf8569360eb30f2d8d85601d9696207fd31919df5a45155
5
5
  SHA512:
6
- metadata.gz: f7d0119c23c3b190a28851c7c976817aba9393cbd8146624c42f3456b78f047be4afb62059109de1c1172292df0fdf9a92ef4cfd6925297bb145fbba52a750e8
7
- data.tar.gz: 5de087ec30bf35dc712e0879fe32f30bea1f13ae408ab356b95cd815b290efa8eef8dec7d79516172b491cbd7e9083391a727d6a0a31e4fa1a6c43fe94c6d92f
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
- 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.2"
2
+ VERSION = "0.1.5"
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.2
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-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