koyo-postgres-replication 0.1.0.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koyo
4
+ module Repl
5
+ # Supports config/initializer or ENV and adds defaults
6
+ # lib/koyo/repl/templates/koyo_postges_replication_config is
7
+ # copied to config/initializers when `rake koyo:repl:install` is
8
+ # run. If you set the associated ENV it will override these settings.
9
+ class Configuration
10
+ attr_writer :auto_create_replication_slot,
11
+ :config_prefix,
12
+ :database_name,
13
+ :disable_logging,
14
+ :slot,
15
+ :sql_delay,
16
+ :test_mode
17
+
18
+ # Try to auto create replication slot if it doesn't exist
19
+ # Defaults to true
20
+ # Override with ENV['KOYO_REPL_AUTO_CREATE_REPLICATION_SLOT']
21
+ def auto_create_replication_slot
22
+ val = @auto_create_replication_slot ||
23
+ ENV["#{config_prefix}_AUTO_CREATE_REPLICATION_SLOT"] ||
24
+ 'true'
25
+ Koyo::Repl::Database.to_bool(val)
26
+ end
27
+
28
+ # Overrides the default prefix of ENV variables
29
+ # Override with ENV['KOYO_REPL_CONFIG_PREFIX']
30
+ def config_prefix
31
+ @config_prefix || ENV['KOYO_REPL_CONFIG_PREFIX'] || 'KOYO_REPL'
32
+ end
33
+
34
+ # Name of config/database.yml connection to use for replication
35
+ #
36
+ # Since this requires admin priveleges you might want to use
37
+ # a different connection to prevent all rails actions having
38
+ # admin priveleges to your DB. Default to whatever the default
39
+ # DB is for the project
40
+ # Override with ENV['KOYO_REPL_DB_CONN_NAME']
41
+ def database_name
42
+ @database_name || ENV["#{config_prefix}_DATABASE_NAME"]
43
+ end
44
+
45
+ # Disables logging (not recommended)
46
+ # Defaults to false
47
+ # Override with ENV['KOYO_REPL_DISABLE_LOGGING']
48
+ def disable_logging
49
+ Koyo::Repl::Database.to_bool(@disable_logging ||
50
+ ENV["#{config_prefix}_DISABLE_LOGGING"])
51
+ end
52
+
53
+ # Replication Slot name - can be any string - but must be
54
+ # unique to your database server.
55
+ #
56
+ # This is the name of the replication slot in postgres
57
+ # You can check replication slots that exist with:
58
+ #
59
+ # select slot_name
60
+ # from pg_replication_slots
61
+ # where
62
+ # and plugin = 'wal2json'
63
+ #
64
+ # Override with ENV['KOYO_REPL_SLOT']
65
+ def slot
66
+ @slot ||
67
+ ENV["#{config_prefix}_SLOT"] ||
68
+ "koyo_repl_#{Koyo::Repl::Database.current_db_name}_#{Rails.env}"
69
+ end
70
+
71
+ # Time to wait before checking Replication Slot again in seconds
72
+ # Note: that if there 10,000 things on the replciation-queue it will
73
+ # process all of those as fast as possible, then pause for this many
74
+ # seconds before re-checking the replication-queue
75
+ # Overide with ENV['KOYO_REPL_SQL_DELAY']
76
+ def sql_delay
77
+ @sql_delay || (ENV["#{config_prefix}_SQL_DELAY"] || 1).to_i
78
+ end
79
+
80
+ # When true we only "peek" the replication slot
81
+ # Peek (when this is false):
82
+ # leaves the data on the postgres-replication queue
83
+ # Read (when this is true):
84
+ # removes data from the postgres-replication queue
85
+ # Defaults to false
86
+ # Override with ENV['KOYO_REPL_TEST_MODE']
87
+ def test_mode
88
+ val = @test_mode || ENV["#{config_prefix}_TEST_MODE"]
89
+ Koyo::Repl::Database.to_bool(val)
90
+ end
91
+
92
+ # Helper method that converts config settings into a hash
93
+ def to_h
94
+ {
95
+ auto_create_replication_slot: auto_create_replication_slot,
96
+ config_prefix: config_prefix,
97
+ database_name: database_name,
98
+ slot: slot,
99
+ sql_delay: sql_delay,
100
+ test_mode: test_mode
101
+ }
102
+ end
103
+
104
+ # Helper method that converts config settings into a string
105
+ def to_s
106
+ to_h.map { |k, v| "#{k}: #{v}" }.join("\n")
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koyo
4
+ module Repl
5
+ # Class wrapper for replication data pulls
6
+ # Example data packet
7
+ #
8
+ # {
9
+ # "change": [
10
+ # {
11
+ # "kind": "update",
12
+ # "schema": "public",
13
+ # "table": "users",
14
+ # "columnnames": ["id", "email", ...],
15
+ # "columntypes": ["integer", "character varying(255)", ...],
16
+ # "columnvalues": [120665, "", ...]
17
+ # "oldkeys": {
18
+ # "keynames": ["id"],
19
+ # "keytypes": ["integer"],
20
+ # "keyvalues": [120665]
21
+ # }
22
+ # }
23
+ # ]
24
+ # }
25
+ class Data
26
+ attr_accessor :row, # raw results from db
27
+ :lsn, # ???
28
+ :xid # uniq id of row returned
29
+
30
+ # Takes a row from ReplDatabase.(peek_slot/read_slot!)
31
+ # @see For details on `row` see:
32
+ # https://github.com/wiseleyb/koyo-postgres-replication/wiki/Koyo::Repl::DataRow-data-spec
33
+ def initialize(row)
34
+ @row = row
35
+ @lsn = row['lsn']
36
+ @xid = row['xid']
37
+ # TODO: find faster JSON lib for this
38
+ @data_rows = Koyo::Repl::Database.parse_json(row['data'])['change']
39
+ end
40
+
41
+ # Collection of Koyo::Repl::DataRow
42
+ #
43
+ # When you read from a replicaiton slot it can return 0-n change
44
+ # events. In general this system works on a row based level,
45
+ # which is a bit slower but is simpler to code.
46
+ def rows
47
+ # @data_rows.map { |d| ReplDataRow.new(xid, d) }
48
+ @data_rows.map { |d| Koyo::Repl::DataRow.new(d) }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koyo
4
+ module Repl
5
+ # Class wrapper for replication row row
6
+ # @see
7
+ # https://github.com/wiseleyb/koyo-postgres-replication/wiki/Koyo::Repl::DataRow-row-spec
8
+ # @see For details on row
9
+ class DataRow
10
+ attr_accessor :row, # raw json of row - see example below
11
+ :kind, # insert/update/delete
12
+ :schema, # always public for this - not needed
13
+ :table, # table being changed
14
+ :id, # table.id
15
+ :id_type, # integer/uuid
16
+ :columns, # all columns from table - array
17
+ :column_types, # all types of columns - array
18
+ :values # all values from table - array
19
+
20
+ # Initialized attributes
21
+ def initialize(row)
22
+ @row = row
23
+ @kind = @row['kind']
24
+ @schema = @row['schema']
25
+ @table = @row['table']
26
+ @columns = @row['columnnames']
27
+ @column_types = @row['columntypes']
28
+ @values = @row['columnvalues']
29
+ check_set_primary_keys
30
+ end
31
+
32
+ # This doesn't work for multiple primary keys right now
33
+ #
34
+ # WARN: this breaks for multiple primary keys
35
+ def check_set_primary_keys
36
+ if @row['oldkeys']
37
+ if @row['oldkeys']['keynames'].size > 1
38
+ raise "This doesn't support multiple keys right now"
39
+ end
40
+
41
+ @id = @row['oldkeys']['keyvalues'].first
42
+ @id_type = @row['oldkeys']['keytypes'].first
43
+ else
44
+ @id = val(:id)
45
+ @id_type = type(:id)
46
+ end
47
+ end
48
+
49
+ # Gets a value for a name from columnsvalues
50
+ # @param name column name
51
+ def val(name)
52
+ values[columns.index(name.to_s)]
53
+ end
54
+
55
+ # Get a val type from columntypes
56
+ # @param name column name
57
+ def type(name)
58
+ column_types[columns.index(name.to_s)]
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ # Example row packet
65
+ #
66
+ # {
67
+ # "change": [
68
+ # {
69
+ # "kind": "delete",
70
+ # "schema": "public",
71
+ # "table": "users",
72
+ # "oldkeys": {
73
+ # "keynames": ["id"],
74
+ # "keytypes": ["integer"],
75
+ # "keyvalues": [123]
76
+ # }
77
+ # },
78
+ # {
79
+ # "kind": "insert",
80
+ # "schema": "public",
81
+ # "table": "users",
82
+ # "columnnames": ["id", "name", "email", ...],
83
+ # "columntypes": ["integer", "character varying", ...],
84
+ # "columnvalues": [234, "User", ...]
85
+ # },
86
+ # {
87
+ # "kind": "update",
88
+ # "schema": "public",
89
+ # "table": "users",
90
+ # "columnnames": ["id", "name", "email", ...],
91
+ # "columntypes": ["integer", "character varying", ...],
92
+ # "columnvalues": [234, "User", ...]
93
+ # "oldkeys": {
94
+ # "keynames": ["id"],
95
+ # "keytypes": ["integer"],
96
+ # "keyvalues": [233]
97
+ # }
98
+ # }
99
+ # ]
100
+ # }
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koyo
4
+ module Repl
5
+ # Basic utilities for postgres replication
6
+ # @see Postgres doc for more options
7
+ # https://www.postgresql.org/docs/9.4/logicaldecoding-example.html
8
+ class Database
9
+ class << self
10
+ # TODO: refactor this out of the config class
11
+ # DB connection name in config/database.yml. Defaults to Rails.env
12
+ # (so standard connection on most rails app). We add this because you need
13
+ # admin priveleges to use replication and some companies have problems with
14
+ # this. Whatever this is called it will have Rails.env tacked on so if it's
15
+ # replication - the connection would be "replciation_#{Rails.env}"
16
+ def conn
17
+ return @conn if @conn
18
+
19
+ conn_name = Koyo::Repl.config.database_name
20
+
21
+ unless conn_name
22
+ @conn = ActiveRecord::Base.connection
23
+ return @conn
24
+ end
25
+
26
+ conn_name = "#{conn_name}_#{Rails.env}"
27
+
28
+ msg = "source=KoyoReplication Connecting to #{conn_name}"
29
+ Rails.logger.info msg
30
+
31
+ config =
32
+ ApplicationRecord.configurations.find_db_config(conn_name)
33
+ ActiveRecord::Base.establish_connection config
34
+ @conn = ActiveRecord::Base.connection
35
+ @conn
36
+ end
37
+
38
+ # Reads from the replication slot.
39
+ # Reading from this marks the rows read (so you won't see them again)
40
+ # For testing you can use `peek_slot` if you want to - which will keep
41
+ # the data in the slot. A known issue is that this slot can grow so
42
+ # large that you it'll time out when trying to read from it. This is a
43
+ # major downside of this approach. Please open an issue if you know a
44
+ # solution.
45
+ def read_slot!
46
+ sql = %(
47
+ SELECT *
48
+ FROM pg_logical_slot_get_changes('#{config_slot}',
49
+ NULL,
50
+ NULL,
51
+ 'pretty-print',
52
+ '1');
53
+ )
54
+ exec_sql(sql)
55
+ end
56
+
57
+ # Peeks at data in the replication-slot. Use this for debugging. This
58
+ # will leave the data in the replication-slot. If
59
+ # Configuration.test_mode=true the code will default to peek'ing
60
+ # instead of reading.
61
+ def peek_slot
62
+ sql = %(
63
+ SELECT *
64
+ FROM pg_logical_slot_peek_changes('#{config_slot}',
65
+ NULL,
66
+ NULL,
67
+ 'pretty-print',
68
+ '1');
69
+ )
70
+ exec_sql(sql)
71
+ end
72
+
73
+ # Checks count of current slot
74
+ def replication_slot_count
75
+ sql = %(
76
+ SELECT count(*)
77
+ FROM pg_logical_slot_peek_changes('#{config_slot}',
78
+ NULL,
79
+ NULL,
80
+ 'pretty-print',
81
+ '1');
82
+ )
83
+ exec_sql(sql).first['count'].to_i
84
+ end
85
+
86
+ # Checks to see if the replication slot exists
87
+ def replication_slot_exists?
88
+ sql = %(
89
+ select count(*)
90
+ from pg_replication_slots
91
+ where
92
+ slot_name = '#{config_slot}'
93
+ and database = '#{current_db_name}'
94
+ and plugin = 'wal2json'
95
+ )
96
+ exec_sql(sql).first['count'].to_i.positive?
97
+ end
98
+
99
+ # Returns all data for current replication slot.
100
+ def replication_slot
101
+ sql = %(
102
+ select *
103
+ from pg_replication_slots
104
+ where plugin = 'wal2json'
105
+ and slot_name = '#{config_slot}'
106
+ and database = '#{current_db_name}'
107
+ )
108
+ exec_sql(sql).first
109
+ end
110
+
111
+ # Returns all replication slots on the system that support wal2json
112
+ def replication_slots
113
+ sql = %(
114
+ select *
115
+ from pg_replication_slots
116
+ where plugin = 'wal2json'
117
+ )
118
+ exec_sql(sql)
119
+ end
120
+
121
+ # Creates a replication slot. You need admin priveleges for this.
122
+ def create_replication_slot!
123
+ return if replication_slot_exists?
124
+
125
+ sql = %(
126
+ SELECT 'init'
127
+ FROM pg_create_logical_replication_slot('#{config_slot}',
128
+ 'wal2json')
129
+ )
130
+ exec_sql(sql)
131
+ end
132
+
133
+ # Deletes replication slot. You need admin priveleges for this.
134
+ def delete_replication_slot!
135
+ return unless replication_slot_exists?
136
+
137
+ sql = %(select pg_drop_replication_slot('#{config_slot}'))
138
+ exec_sql(sql)
139
+ end
140
+
141
+ # Drops and recreates replication slot
142
+ def drop_create_slot!
143
+ Koyo::Repl::Database.delete_replication_slot!
144
+ Koyo::Repl::Database.create_replication_slot!
145
+ end
146
+
147
+ # Checks the wal_level - which should be "logical" if things are setup
148
+ # properly. You can change the wal_level in postgres config. See the
149
+ # README for details on on this. When you change this you need to
150
+ # restart the postgres server
151
+ def wal_level
152
+ sql = %(show wal_level)
153
+ exec_sql(sql).first['wal_level']
154
+ end
155
+
156
+ # Helper - just returns the configured database name being used. Can
157
+ # be changed using Configuration.slot
158
+ def current_db_name
159
+ Rails.configuration.database_configuration[Rails.env]['database']
160
+ end
161
+
162
+ # Runs SQL commands
163
+ def exec_sql(sql)
164
+ # ActiveRecord::Base.connection.execute(sql)
165
+ conn.execute(sql)
166
+ end
167
+
168
+ # wrap this to support faster JSON parsing in the future
169
+ def parse_json(json)
170
+ JSON.parse(json)
171
+ end
172
+
173
+ def config_slot
174
+ Koyo::Repl.config.slot
175
+ end
176
+
177
+ def to_bool(val)
178
+ %w[1 true t yes].include?(val.to_s.downcase.strip)
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koyo
4
+ module Repl
5
+ # Provides state and debugging info for Repl setup
6
+ # can be run with rake koyo::repl::diagnostics
7
+ class Diagnostics
8
+ # For use with rake koyo::repl::diagnostics
9
+ # Outputs repl setup and current state info
10
+ def rake_info
11
+ [
12
+ "Config settings: \n#{h_to_s(Koyo::Repl.config.to_h)}",
13
+ "Replication slot exists: #{replication_slot_exists?}",
14
+ "Registered tables: \n#{h_to_s(registered_tables)}",
15
+ "Can connect to db: #{can_connect?}",
16
+ "Connection adapter: #{adapter_name}",
17
+ "Wal Level (should be 'logical'): #{wal_level}",
18
+ "Can access replication slot: #{can_access_replication_slot?}",
19
+ "Replication slot count: #{repl_count}"
20
+ ]
21
+ end
22
+
23
+ # Checks if replication slot exists.
24
+ # TODO: maybe this should create if it doesn't?
25
+ def replication_slot_exists?
26
+ Koyo::Repl::Database.replication_slot_exists?
27
+ rescue StandardError => e
28
+ "Error: #{e.message}"
29
+ end
30
+
31
+ # Returns list of models that have registered a call back
32
+ def registered_tables
33
+ res =
34
+ Koyo::Repl::PostgresServer.tables_that_handle_koyo_replication || {}
35
+ if res == {}
36
+ res = {
37
+ warning: 'No tables registered - see example file in '\
38
+ 'app/models/koyo_repl_model_example.rb to see '\
39
+ 'how to monitor replication for a table from a model. '\
40
+ 'This is optional - you can just use '\
41
+ 'app/models/koyo_repl_handler_service.rb as a catch all '\
42
+ 'for all replication events if you want.'
43
+ }
44
+ end
45
+ res
46
+ rescue StandardError => e
47
+ "Error: #{e.message}"
48
+ end
49
+
50
+ # Checks connection to database
51
+ def can_connect?
52
+ Koyo::Repl::Database.conn.execute('select now()')
53
+ true
54
+ rescue StandardError => e
55
+ "Error: #{e.message}"
56
+ end
57
+
58
+ # Checks access/permissions to replication slot
59
+ def can_access_replication_slot?
60
+ Koyo::Repl::Database.peek_slot
61
+ true
62
+ rescue StandardError => e
63
+ "Error: #{e.message}"
64
+ end
65
+
66
+ # Checks that replication slot exists
67
+ def repl_count
68
+ Koyo::Repl::Database.replication_slot_count
69
+ rescue StandardError => e
70
+ "Error: #{e.message}"
71
+ end
72
+
73
+ # Returns configured database name
74
+ def adapter_name
75
+ Koyo::Repl::Datasbase.conn.adapter_name
76
+ rescue StandardError => e
77
+ "Error: #{e.message}"
78
+ end
79
+
80
+ # Returns configured wal_level. Should be "logical"
81
+ def wal_level
82
+ Koyo::Repl::Database.wal_level
83
+ rescue StandardError => e
84
+ "Error: #{e.message}"
85
+ end
86
+
87
+ # Helper - outputs a hash
88
+ def h_to_s(hash)
89
+ hash.map { |k, v| " #{k}: #{v}" }.join("\n")
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koyo
4
+ module Repl
5
+ # Calls back to Rails project
6
+ # This is primarily for overriding in the rails app to do things like ping
7
+ # Slack, or restart things on errors, etc
8
+ class EventHandlerService
9
+ class << self
10
+ # example row:
11
+ # User.handle_replcation called
12
+ def koyo_handle_all_replication(row)
13
+ KoyoReplHandlerService.koyo_handle_all_replication(row)
14
+ end
15
+
16
+ # Called whenever an error is raised in Koyo::Repl code
17
+ def koyo_error(err)
18
+ KoyoReplHandlerService.koyo_error(err)
19
+ end
20
+
21
+ # log_level: :debug, :info, :warn, :error, :fatal
22
+ # Example of message
23
+ # source=KoyoReplication logid=d7f1f0bb2a
24
+ # message=Init: Finding models that support koyo_repl_handler
25
+ # You can use this as a catch all for any log event or use methods
26
+ # below if that's easier
27
+ def koyo_log_event(message, log_level)
28
+ KoyoReplHandlerService.koyo_log_event(message, log_level)
29
+ end
30
+
31
+ # Called whenever Rails.logger.debug is called from Koyo::Repl code
32
+ def koyo_log_event_debug(message)
33
+ KoyoReplHandlerService.koyo_log_event_debug(message)
34
+ end
35
+
36
+ # Called whenever Rails.logger.debug is called from Koyo::Repl code
37
+ def koyo_log_event_info(message)
38
+ KoyoReplHandlerService.koyo_log_event_info(message)
39
+ end
40
+
41
+ # Called whenever Rails.logger.debug is called from Koyo::Repl code
42
+ def koyo_log_event_warn(message)
43
+ KoyoReplHandlerService.koyo_log_event_warn(message)
44
+ end
45
+
46
+ # Called whenever Rails.logger.debug is called from Koyo::Repl code
47
+ def koyo_log_event_error(message)
48
+ KoyoReplHandlerService.koyo_log_event_error(message)
49
+ end
50
+
51
+ # Called whenever Rails.logger.debug is called from Koyo::Repl code
52
+ def koyo_log_event_fatal(message)
53
+ KoyoReplHandlerService.koyo_log_event_fatal(message)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koyo
4
+ module Repl
5
+ # Copies required files to Rails project
6
+ class Install
7
+ def self.copy!
8
+ kri = Koyo::Repl::Install.new
9
+ kri.copy!
10
+ Koyo::Repl::Database.drop_create_slot!
11
+ end
12
+
13
+ # Copies files unless they already exist
14
+ def copy!
15
+ debugp ''
16
+ debugp '-' * 80
17
+ copy("#{template_path}/koyo_postgres_replication_config.txt",
18
+ "#{rails_path}/config/initializers/"\
19
+ 'koyo_postgres_replication_config.rb')
20
+
21
+ copy("#{template_path}/koyo_repl_handler_service.txt",
22
+ "#{rails_path}/app/models/koyo_repl_handler_service.rb")
23
+
24
+ copy("#{template_path}/koyo_repl_model_example.txt",
25
+ "#{rails_path}/app/models/koyo_repl_model_example.rb")
26
+ debugp '-' * 80
27
+ end
28
+
29
+ # Debugging helper
30
+ def debugp(msg)
31
+ puts msg unless Rails.env.test? # don't pollute spec output
32
+ end
33
+
34
+ # Helper for checking if files exist
35
+ # @param fname file name (with path)
36
+ def file_exists?(fname)
37
+ if File.exist?(fname)
38
+ puts "SKIPPING: #{fname} exists. Delete this file to recreated it."
39
+ return true
40
+ end
41
+ false
42
+ end
43
+
44
+ # Copies individual file
45
+ # @param from_fname file to copy from
46
+ # @param to_fname file to copy to
47
+ def copy(from_fname, to_fname)
48
+ return if file_exists?(to_fname)
49
+
50
+ puts "ADDING #{to_fname}"
51
+ dir_name = File.dirname(to_fname)
52
+ FileUtils.mkdir_p(dir_name)
53
+ FileUtils.cp(from_fname, to_fname.gsub('.txt', '.rb'))
54
+ end
55
+
56
+ # Path of template files to copy
57
+ def template_path
58
+ "#{File.dirname(__FILE__)}/templates"
59
+ end
60
+
61
+ # Rails path helper
62
+ def rails_path
63
+ Rails.root.to_s
64
+ end
65
+ end
66
+ end
67
+ end