whodunit-chronicles 0.3.0 → 0.4.0

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -226
  3. data/LICENSE +1 -1
  4. data/README.md +96 -599
  5. data/exe/whodunit-chronicles +6 -0
  6. data/lib/whodunit/chronicles/chronicler.rb +62 -0
  7. data/lib/whodunit/chronicles/cli.rb +131 -0
  8. data/lib/whodunit/chronicles/errors.rb +7 -33
  9. data/lib/whodunit/chronicles/ledger.rb +69 -0
  10. data/lib/whodunit/chronicles/ledger_entry.rb +143 -0
  11. data/lib/whodunit/chronicles/ledger_factory.rb +66 -0
  12. data/lib/whodunit/chronicles/ledgers/file_ledger.rb +56 -0
  13. data/lib/whodunit/chronicles/ledgers/memory_ledger.rb +29 -0
  14. data/lib/whodunit/chronicles/ledgers/sqlite_ledger.rb +172 -0
  15. data/lib/whodunit/chronicles/version.rb +2 -1
  16. data/lib/whodunit/chronicles.rb +12 -65
  17. data/lib/whodunit-chronicles.rb +0 -1
  18. data/sig/whodunit/chronicles/chronicler.rbs +14 -0
  19. data/sig/whodunit/chronicles/cli.rbs +17 -0
  20. data/sig/whodunit/chronicles/errors.rbs +15 -0
  21. data/sig/whodunit/chronicles/ledger.rbs +13 -0
  22. data/sig/whodunit/chronicles/ledger_entry.rbs +62 -0
  23. data/sig/whodunit/chronicles/ledger_factory.rbs +14 -0
  24. data/sig/whodunit/chronicles/ledgers/file_ledger.rbs +14 -0
  25. data/sig/whodunit/chronicles/ledgers/memory_ledger.rbs +12 -0
  26. data/sig/whodunit/chronicles/ledgers/sqlite_ledger.rbs +30 -0
  27. data/sig/whodunit/chronicles.rbs +5 -0
  28. metadata +40 -326
  29. data/.codeclimate.yml +0 -50
  30. data/.rubocop.yml +0 -93
  31. data/.yardopts +0 -14
  32. data/CODE_OF_CONDUCT.md +0 -132
  33. data/CONTRIBUTING.md +0 -186
  34. data/Rakefile +0 -18
  35. data/docker/mysql/init.sql +0 -33
  36. data/docker/postgres/init.sql +0 -40
  37. data/docker-compose.yml +0 -138
  38. data/examples/images/campaign-performance-analytics.png +0 -0
  39. data/examples/images/candidate-journey-analytics.png +0 -0
  40. data/examples/images/recruitment-funnel-analytics.png +0 -0
  41. data/lib/.gitkeep +0 -0
  42. data/lib/whodunit/chronicles/adapter_loader.rb +0 -69
  43. data/lib/whodunit/chronicles/adapters/mysql.rb +0 -261
  44. data/lib/whodunit/chronicles/adapters/postgresql.rb +0 -278
  45. data/lib/whodunit/chronicles/change_event.rb +0 -201
  46. data/lib/whodunit/chronicles/composite_processor.rb +0 -86
  47. data/lib/whodunit/chronicles/configuration.rb +0 -112
  48. data/lib/whodunit/chronicles/connection.rb +0 -88
  49. data/lib/whodunit/chronicles/persistence.rb +0 -129
  50. data/lib/whodunit/chronicles/processor.rb +0 -127
  51. data/lib/whodunit/chronicles/service.rb +0 -207
  52. data/lib/whodunit/chronicles/stream_adapter.rb +0 -91
  53. data/lib/whodunit/chronicles/table.rb +0 -120
  54. data/whodunit-chronicles.gemspec +0 -79
@@ -1,261 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'trilogy'
4
- require 'uri'
5
-
6
- module Whodunit
7
- module Chronicles
8
- module Adapters
9
- # MySQL/MariaDB binary log streaming adapter
10
- #
11
- # Uses MySQL's binary log replication to stream database changes
12
- # without impacting application performance.
13
- class MySQL < Chronicles::StreamAdapter
14
- DEFAULT_SERVER_ID = 1001
15
-
16
- attr_reader :connection, :database_url, :server_id, :binlog_file, :binlog_position
17
-
18
- def initialize(
19
- database_url: Chronicles.config.database_url,
20
- server_id: DEFAULT_SERVER_ID,
21
- logger: Chronicles.logger
22
- )
23
- super(logger: logger)
24
- @database_url = database_url
25
- @server_id = server_id
26
- @connection = nil
27
- @binlog_file = nil
28
- @binlog_position = nil
29
- @binlog_checksum = true
30
- end
31
-
32
- # Start streaming binary log changes
33
- def start_streaming(&)
34
- raise ArgumentError, 'Block required for processing events' unless block_given?
35
-
36
- log(:info, 'Starting MySQL binary log streaming')
37
-
38
- establish_connection
39
- ensure_setup
40
-
41
- self.running = true
42
- fetch_current_position
43
-
44
- log(:info, 'Starting replication from position',
45
- file: @binlog_file, position: @binlog_position)
46
-
47
- begin
48
- stream_binlog_events(&)
49
- rescue StandardError => e
50
- log(:error, 'Streaming error', error: e.message, backtrace: e.backtrace.first(5))
51
- raise ReplicationError, "Failed to stream changes: #{e.message}"
52
- ensure
53
- self.running = false
54
- end
55
- end
56
-
57
- # Stop streaming
58
- def stop_streaming
59
- log(:info, 'Stopping MySQL binary log streaming')
60
- self.running = false
61
- close_connection
62
- end
63
-
64
- # Get current replication position
65
- def current_position
66
- return "#{@binlog_file}:#{@binlog_position}" if @binlog_file && @binlog_position
67
-
68
- fetch_current_position
69
- "#{@binlog_file}:#{@binlog_position}"
70
- end
71
-
72
- # Set up binary log replication
73
- def setup
74
- log(:info, 'Setting up MySQL binary log replication')
75
-
76
- establish_connection
77
- validate_binlog_format
78
- validate_server_id
79
- enable_binlog_checksum
80
-
81
- log(:info, 'MySQL setup completed successfully')
82
- end
83
-
84
- # Remove binary log replication setup (minimal cleanup needed)
85
- def teardown
86
- log(:info, 'Tearing down MySQL binary log replication')
87
- close_connection
88
- log(:info, 'MySQL teardown completed')
89
- end
90
-
91
- # Test database connection
92
- def test_connection
93
- establish_connection
94
- result = @connection.query('SELECT @@hostname, @@version, @@server_id')
95
- info = result.first
96
-
97
- log(:info, 'Connection test successful',
98
- hostname: info['@@hostname'],
99
- version: info['@@version'],
100
- server_id: info['@@server_id'])
101
-
102
- true
103
- rescue StandardError => e
104
- log(:error, 'Connection test failed', error: e.message)
105
- false
106
- end
107
-
108
- private
109
-
110
- def establish_connection
111
- return if @connection&.ping
112
-
113
- parsed_url = parse_database_url(@database_url)
114
-
115
- @connection = Trilogy.new(
116
- host: parsed_url[:host],
117
- port: parsed_url[:port] || 3306,
118
- username: parsed_url[:username],
119
- password: parsed_url[:password],
120
- database: parsed_url[:database],
121
- ssl: parsed_url[:ssl],
122
- )
123
-
124
- log(:debug, 'Established MySQL connection',
125
- host: parsed_url[:host],
126
- database: parsed_url[:database])
127
- rescue StandardError => e
128
- log(:error, 'Failed to establish connection', error: e.message)
129
- raise AdapterLoadError, "Connection failed: #{e.message}"
130
- end
131
-
132
- def close_connection
133
- @connection&.close
134
- @connection = nil
135
- end
136
-
137
- def parse_database_url(url)
138
- uri = URI.parse(url)
139
- {
140
- host: uri.host,
141
- port: uri.port,
142
- username: uri.user,
143
- password: uri.password,
144
- database: uri.path&.sub('/', ''),
145
- ssl: uri.query&.include?('ssl=true'),
146
- }
147
- end
148
-
149
- def ensure_setup
150
- validate_binlog_format
151
- validate_server_id
152
- end
153
-
154
- def validate_binlog_format
155
- result = @connection.query('SELECT @@binlog_format')
156
- format = result.first['@@binlog_format']
157
-
158
- unless %w[ROW MIXED].include?(format)
159
- raise ReplicationError,
160
- "Binary log format must be ROW or MIXED, currently: #{format}"
161
- end
162
-
163
- log(:debug, 'Binary log format validated', format: format)
164
- end
165
-
166
- def validate_server_id
167
- result = @connection.query('SELECT @@server_id')
168
- current_server_id = result.first['@@server_id'].to_i
169
-
170
- if current_server_id == @server_id
171
- raise ReplicationError,
172
- "Server ID conflict: #{@server_id} is already in use"
173
- end
174
-
175
- log(:debug, 'Server ID validated',
176
- current: current_server_id,
177
- replication: @server_id)
178
- end
179
-
180
- def enable_binlog_checksum
181
- @connection.query('SET @master_binlog_checksum = @@global.binlog_checksum')
182
- log(:debug, 'Binary log checksum enabled')
183
- end
184
-
185
- def fetch_current_position
186
- result = @connection.query('SHOW MASTER STATUS')
187
- status = result.first
188
-
189
- raise ReplicationError, 'Unable to fetch master status - binary logging may be disabled' unless status
190
-
191
- @binlog_file = status['File']
192
- @binlog_position = status['Position']
193
- log(:debug, 'Fetched master position',
194
- file: @binlog_file,
195
- position: @binlog_position)
196
- end
197
-
198
- def stream_binlog_events(&)
199
- # Register as replica server
200
- register_replica_server
201
-
202
- # Request binary log dump
203
- request_binlog_dump
204
-
205
- # Process binary log events
206
- process_binlog_stream(&)
207
- rescue StandardError => e
208
- log(:error, 'Binary log streaming error', error: e.message)
209
- raise
210
- end
211
-
212
- def register_replica_server
213
- # This would typically use COM_REGISTER_SLAVE MySQL protocol command
214
- # For now, we'll use a simplified approach
215
- log(:debug, 'Registering as replica server', server_id: @server_id)
216
-
217
- # NOTE: Full implementation would require low-level MySQL protocol handling
218
- # This is a placeholder for the binary log streaming setup
219
- end
220
-
221
- def request_binlog_dump
222
- log(:debug, 'Requesting binary log dump',
223
- file: @binlog_file,
224
- position: @binlog_position)
225
-
226
- # This would use COM_BINLOG_DUMP MySQL protocol command
227
- # Full implementation requires binary protocol handling
228
- end
229
-
230
- def process_binlog_stream(&)
231
- # This would process the binary log event stream
232
- # Each event would be parsed and converted to a ChangeEvent
233
-
234
- log(:info, 'Processing binary log stream (placeholder implementation)')
235
-
236
- # Placeholder: In a real implementation, this would:
237
- # 1. Read binary log events from the stream
238
- # 2. Parse event headers and data
239
- # 3. Convert to ChangeEvent objects
240
- # 4. Yield each event to the block
241
-
242
- # For now, we'll simulate with a warning
243
- log(:warn, 'MySQL binary log streaming requires full protocol implementation')
244
-
245
- # Yield a placeholder change event to demonstrate the interface
246
- change_event = ChangeEvent.new(
247
- table_name: 'example_table',
248
- action: 'INSERT',
249
- primary_key: { id: 1 },
250
- new_data: { id: 1, name: 'test' },
251
- old_data: nil,
252
- timestamp: Time.now,
253
- metadata: { position: current_position },
254
- )
255
-
256
- yield(change_event) if block_given?
257
- end
258
- end
259
- end
260
- end
261
- end
@@ -1,278 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'pg'
4
-
5
- module Whodunit
6
- module Chronicles
7
- module Adapters
8
- # PostgreSQL logical replication adapter
9
- #
10
- # Uses PostgreSQL's logical replication functionality to stream
11
- # database changes via WAL decoding without impacting application performance.
12
- class PostgreSQL < Chronicles::StreamAdapter
13
- DEFAULT_PLUGIN = 'pgoutput'
14
-
15
- attr_reader :connection, :replication_connection, :publication_name, :slot_name
16
-
17
- def initialize(
18
- database_url: Chronicles.config.database_url,
19
- publication_name: Chronicles.config.publication_name,
20
- slot_name: Chronicles.config.replication_slot_name,
21
- logger: Chronicles.logger
22
- )
23
- super(logger: logger)
24
- @database_url = database_url
25
- @publication_name = publication_name
26
- @slot_name = slot_name
27
- @connection = nil
28
- @replication_connection = nil
29
- @last_lsn = nil
30
- end
31
-
32
- # Start streaming logical replication changes
33
- def start_streaming(&)
34
- raise ArgumentError, 'Block required for processing events' unless block_given?
35
-
36
- log(:info, 'Starting PostgreSQL logical replication streaming')
37
-
38
- establish_connections
39
- ensure_setup
40
-
41
- self.running = true
42
- self.position = confirmed_flush_lsn || '0/0'
43
-
44
- log(:info, 'Starting replication from LSN', lsn: @position)
45
-
46
- begin
47
- stream_changes(&)
48
- rescue StandardError => e
49
- log(:error, 'Streaming error', error: e.message, backtrace: e.backtrace.first(5))
50
- raise ReplicationError, "Failed to stream changes: #{e.message}"
51
- ensure
52
- self.running = false
53
- end
54
- end
55
-
56
- # Stop streaming
57
- def stop_streaming
58
- log(:info, 'Stopping PostgreSQL logical replication streaming')
59
- self.running = false
60
- close_connections
61
- end
62
-
63
- # Get current replication position
64
- def current_position
65
- @last_lsn || confirmed_flush_lsn
66
- end
67
-
68
- # Set up logical replication (publication and slot)
69
- def setup
70
- log(:info, 'Setting up PostgreSQL logical replication')
71
-
72
- establish_connection
73
- create_publication
74
- create_replication_slot
75
-
76
- log(:info, 'PostgreSQL setup completed successfully')
77
- end
78
-
79
- # Remove logical replication setup
80
- def teardown
81
- log(:info, 'Tearing down PostgreSQL logical replication')
82
-
83
- establish_connection
84
- drop_replication_slot
85
- drop_publication
86
-
87
- log(:info, 'PostgreSQL teardown completed')
88
- ensure
89
- close_connections
90
- end
91
-
92
- # Test database connection
93
- def test_connection
94
- establish_connection
95
- result = @connection.exec('SELECT current_database(), current_user, version()')
96
- db_info = result.first
97
-
98
- log(:info, 'Connection test successful',
99
- database: db_info['current_database'],
100
- user: db_info['current_user'],
101
- version: db_info['version'])
102
-
103
- true
104
- rescue PG::Error => e
105
- log(:error, 'Connection test failed', error: e.message)
106
- false
107
- ensure
108
- result&.clear
109
- end
110
-
111
- private
112
-
113
- def establish_connections
114
- establish_connection
115
- establish_replication_connection
116
- end
117
-
118
- def establish_connection
119
- return if @connection && !@connection.finished?
120
-
121
- @connection = PG.connect(@database_url)
122
- @connection.type_map_for_results = PG::BasicTypeMapForResults.new(@connection)
123
- end
124
-
125
- def establish_replication_connection
126
- return if @replication_connection && !@replication_connection.finished?
127
-
128
- # Parse connection URL and add replication parameter
129
- uri = URI.parse(@database_url)
130
- repl_params = URI.decode_www_form(uri.query || '')
131
- repl_params << %w[replication database]
132
- uri.query = URI.encode_www_form(repl_params)
133
-
134
- @replication_connection = PG.connect(uri.to_s)
135
- end
136
-
137
- def close_connections
138
- @connection&.close
139
- @replication_connection&.close
140
- @connection = nil
141
- @replication_connection = nil
142
- end
143
-
144
- def ensure_setup
145
- unless publication_exists?
146
- raise ReplicationError, "Publication '#{publication_name}' does not exist. Run #setup first."
147
- end
148
-
149
- return if replication_slot_exists?
150
-
151
- raise ReplicationError, "Replication slot '#{slot_name}' does not exist. Run #setup first."
152
- end
153
-
154
- def stream_changes(&)
155
- copy_sql = build_copy_statement
156
- log(:debug, 'Starting COPY command', sql: copy_sql)
157
-
158
- @replication_connection.exec(copy_sql)
159
-
160
- while running?
161
- data = @replication_connection.get_copy_data(async: false)
162
- break unless data
163
-
164
- process_wal_data(data, &)
165
- end
166
- end
167
-
168
- def build_copy_statement
169
- options = [
170
- "proto_version '1'",
171
- "publication_names '#{publication_name}'",
172
- ].join(', ')
173
-
174
- "COPY (SELECT * FROM pg_logical_slot_get_changes('#{slot_name}', NULL, NULL, #{options})) TO STDOUT"
175
- end
176
-
177
- def process_wal_data(data)
178
- # Parse pgoutput protocol message
179
- # This is a simplified version - full implementation would need
180
- # to properly decode the binary protocol
181
- log(:debug, 'Processing WAL data', size: data.bytesize)
182
-
183
- # For now, we'll parse text-based logical decoding output
184
- # In production, this should parse the binary pgoutput format
185
- change_event = parse_logical_message(data)
186
- yield(change_event) if change_event
187
- rescue StandardError => e
188
- log(:error, 'Error processing WAL data', error: e.message, data: data.inspect)
189
- end
190
-
191
- def parse_logical_message(data)
192
- # Simplified parser for demonstration
193
- # Real implementation would parse pgoutput binary protocol
194
- lines = data.strip.split("\n")
195
- return unless lines.any?
196
-
197
- # This is a placeholder - would need full pgoutput protocol parsing
198
- log(:debug, 'Parsed logical message', lines: lines.size)
199
- nil
200
- end
201
-
202
- def create_publication
203
- if publication_exists?
204
- log(:info, 'Publication already exists', name: publication_name)
205
- return
206
- end
207
-
208
- sql = "CREATE PUBLICATION #{publication_name} FOR ALL TABLES"
209
- @connection.exec(sql)
210
- log(:info, 'Created publication', name: publication_name)
211
- end
212
-
213
- def drop_publication
214
- return unless publication_exists?
215
-
216
- sql = "DROP PUBLICATION IF EXISTS #{publication_name}"
217
- @connection.exec(sql)
218
- log(:info, 'Dropped publication', name: publication_name)
219
- end
220
-
221
- def create_replication_slot
222
- if replication_slot_exists?
223
- log(:info, 'Replication slot already exists', name: slot_name)
224
- return
225
- end
226
-
227
- sql = "SELECT pg_create_logical_replication_slot('#{slot_name}', '#{DEFAULT_PLUGIN}')"
228
- result = @connection.exec(sql)
229
- slot_info = result.first
230
-
231
- log(:info, 'Created replication slot',
232
- name: slot_name,
233
- lsn: slot_info['lsn'])
234
- ensure
235
- result&.clear
236
- end
237
-
238
- def drop_replication_slot
239
- return unless replication_slot_exists?
240
-
241
- sql = "SELECT pg_drop_replication_slot('#{slot_name}')"
242
- @connection.exec(sql)
243
- log(:info, 'Dropped replication slot', name: slot_name)
244
- end
245
-
246
- def publication_exists?
247
- sql = 'SELECT 1 FROM pg_publication WHERE pubname = $1'
248
- result = @connection.exec_params(sql, [publication_name])
249
- exists = result.ntuples.positive?
250
- result.clear
251
- exists
252
- end
253
-
254
- def replication_slot_exists?
255
- sql = 'SELECT 1 FROM pg_replication_slots WHERE slot_name = $1'
256
- result = @connection.exec_params(sql, [slot_name])
257
- exists = result.ntuples.positive?
258
- result.clear
259
- exists
260
- end
261
-
262
- def confirmed_flush_lsn
263
- sql = 'SELECT confirmed_flush_lsn FROM pg_replication_slots WHERE slot_name = $1'
264
- result = @connection.exec_params(sql, [slot_name])
265
-
266
- if result.ntuples.positive?
267
- lsn = result.first['confirmed_flush_lsn']
268
- result.clear
269
- lsn
270
- else
271
- result.clear
272
- nil
273
- end
274
- end
275
- end
276
- end
277
- end
278
- end