koyo-postgres-replication 0.1.0.pre

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.
@@ -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