andyjeffries-rubyrep 1.2.1
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.
- data/History.txt +83 -0
- data/License.txt +20 -0
- data/Manifest.txt +151 -0
- data/README.txt +37 -0
- data/bin/rubyrep +8 -0
- data/lib/rubyrep.rb +72 -0
- data/lib/rubyrep/base_runner.rb +195 -0
- data/lib/rubyrep/command_runner.rb +144 -0
- data/lib/rubyrep/committers/buffered_committer.rb +151 -0
- data/lib/rubyrep/committers/committers.rb +152 -0
- data/lib/rubyrep/configuration.rb +275 -0
- data/lib/rubyrep/connection_extenders/connection_extenders.rb +165 -0
- data/lib/rubyrep/connection_extenders/jdbc_extender.rb +65 -0
- data/lib/rubyrep/connection_extenders/mysql_extender.rb +59 -0
- data/lib/rubyrep/connection_extenders/postgresql_extender.rb +277 -0
- data/lib/rubyrep/database_proxy.rb +52 -0
- data/lib/rubyrep/direct_table_scan.rb +75 -0
- data/lib/rubyrep/generate_runner.rb +105 -0
- data/lib/rubyrep/initializer.rb +39 -0
- data/lib/rubyrep/log_helper.rb +30 -0
- data/lib/rubyrep/logged_change.rb +160 -0
- data/lib/rubyrep/logged_change_loader.rb +197 -0
- data/lib/rubyrep/noisy_connection.rb +80 -0
- data/lib/rubyrep/proxied_table_scan.rb +171 -0
- data/lib/rubyrep/proxy_block_cursor.rb +145 -0
- data/lib/rubyrep/proxy_connection.rb +431 -0
- data/lib/rubyrep/proxy_cursor.rb +44 -0
- data/lib/rubyrep/proxy_row_cursor.rb +43 -0
- data/lib/rubyrep/proxy_runner.rb +89 -0
- data/lib/rubyrep/replication_difference.rb +100 -0
- data/lib/rubyrep/replication_extenders/mysql_replication.rb +271 -0
- data/lib/rubyrep/replication_extenders/postgresql_replication.rb +236 -0
- data/lib/rubyrep/replication_extenders/replication_extenders.rb +26 -0
- data/lib/rubyrep/replication_helper.rb +142 -0
- data/lib/rubyrep/replication_initializer.rb +327 -0
- data/lib/rubyrep/replication_run.rb +142 -0
- data/lib/rubyrep/replication_runner.rb +166 -0
- data/lib/rubyrep/replicators/replicators.rb +42 -0
- data/lib/rubyrep/replicators/two_way_replicator.rb +361 -0
- data/lib/rubyrep/scan_progress_printers/progress_bar.rb +65 -0
- data/lib/rubyrep/scan_progress_printers/scan_progress_printers.rb +65 -0
- data/lib/rubyrep/scan_report_printers/scan_detail_reporter.rb +111 -0
- data/lib/rubyrep/scan_report_printers/scan_report_printers.rb +67 -0
- data/lib/rubyrep/scan_report_printers/scan_summary_reporter.rb +75 -0
- data/lib/rubyrep/scan_runner.rb +25 -0
- data/lib/rubyrep/session.rb +230 -0
- data/lib/rubyrep/sync_helper.rb +121 -0
- data/lib/rubyrep/sync_runner.rb +31 -0
- data/lib/rubyrep/syncers/syncers.rb +112 -0
- data/lib/rubyrep/syncers/two_way_syncer.rb +174 -0
- data/lib/rubyrep/table_scan.rb +54 -0
- data/lib/rubyrep/table_scan_helper.rb +46 -0
- data/lib/rubyrep/table_sorter.rb +70 -0
- data/lib/rubyrep/table_spec_resolver.rb +142 -0
- data/lib/rubyrep/table_sync.rb +90 -0
- data/lib/rubyrep/task_sweeper.rb +77 -0
- data/lib/rubyrep/trigger_mode_switcher.rb +63 -0
- data/lib/rubyrep/type_casting_cursor.rb +31 -0
- data/lib/rubyrep/uninstall_runner.rb +93 -0
- data/lib/rubyrep/version.rb +9 -0
- data/rubyrep +8 -0
- data/rubyrep.bat +4 -0
- data/setup.rb +1585 -0
- data/spec/base_runner_spec.rb +218 -0
- data/spec/buffered_committer_spec.rb +274 -0
- data/spec/command_runner_spec.rb +145 -0
- data/spec/committers_spec.rb +178 -0
- data/spec/configuration_spec.rb +203 -0
- data/spec/connection_extender_interface_spec.rb +141 -0
- data/spec/connection_extenders_registration_spec.rb +164 -0
- data/spec/database_proxy_spec.rb +48 -0
- data/spec/database_rake_spec.rb +40 -0
- data/spec/db_specific_connection_extenders_spec.rb +34 -0
- data/spec/db_specific_replication_extenders_spec.rb +38 -0
- data/spec/direct_table_scan_spec.rb +61 -0
- data/spec/dolphins.jpg +0 -0
- data/spec/generate_runner_spec.rb +84 -0
- data/spec/initializer_spec.rb +46 -0
- data/spec/log_helper_spec.rb +39 -0
- data/spec/logged_change_loader_spec.rb +68 -0
- data/spec/logged_change_spec.rb +470 -0
- data/spec/noisy_connection_spec.rb +78 -0
- data/spec/postgresql_replication_spec.rb +48 -0
- data/spec/postgresql_schema_support_spec.rb +212 -0
- data/spec/postgresql_support_spec.rb +63 -0
- data/spec/progress_bar_spec.rb +77 -0
- data/spec/proxied_table_scan_spec.rb +151 -0
- data/spec/proxy_block_cursor_spec.rb +197 -0
- data/spec/proxy_connection_spec.rb +423 -0
- data/spec/proxy_cursor_spec.rb +56 -0
- data/spec/proxy_row_cursor_spec.rb +66 -0
- data/spec/proxy_runner_spec.rb +70 -0
- data/spec/replication_difference_spec.rb +161 -0
- data/spec/replication_extender_interface_spec.rb +367 -0
- data/spec/replication_extenders_spec.rb +32 -0
- data/spec/replication_helper_spec.rb +178 -0
- data/spec/replication_initializer_spec.rb +509 -0
- data/spec/replication_run_spec.rb +443 -0
- data/spec/replication_runner_spec.rb +254 -0
- data/spec/replicators_spec.rb +36 -0
- data/spec/rubyrep_spec.rb +8 -0
- data/spec/scan_detail_reporter_spec.rb +119 -0
- data/spec/scan_progress_printers_spec.rb +68 -0
- data/spec/scan_report_printers_spec.rb +67 -0
- data/spec/scan_runner_spec.rb +50 -0
- data/spec/scan_summary_reporter_spec.rb +61 -0
- data/spec/session_spec.rb +253 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +305 -0
- data/spec/strange_name_support_spec.rb +135 -0
- data/spec/sync_helper_spec.rb +169 -0
- data/spec/sync_runner_spec.rb +78 -0
- data/spec/syncers_spec.rb +171 -0
- data/spec/table_scan_helper_spec.rb +36 -0
- data/spec/table_scan_spec.rb +49 -0
- data/spec/table_sorter_spec.rb +30 -0
- data/spec/table_spec_resolver_spec.rb +111 -0
- data/spec/table_sync_spec.rb +140 -0
- data/spec/task_sweeper_spec.rb +47 -0
- data/spec/trigger_mode_switcher_spec.rb +83 -0
- data/spec/two_way_replicator_spec.rb +721 -0
- data/spec/two_way_syncer_spec.rb +256 -0
- data/spec/type_casting_cursor_spec.rb +50 -0
- data/spec/uninstall_runner_spec.rb +93 -0
- metadata +190 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require 'timeout'
|
|
2
|
+
|
|
3
|
+
module RR
|
|
4
|
+
|
|
5
|
+
# Executes a single replication run
|
|
6
|
+
class ReplicationRun
|
|
7
|
+
|
|
8
|
+
# The current Session object
|
|
9
|
+
attr_accessor :session
|
|
10
|
+
|
|
11
|
+
# The current TaskSweeper
|
|
12
|
+
attr_accessor :sweeper
|
|
13
|
+
|
|
14
|
+
# An array of ReplicationDifference which originally failed replication but should be tried one more time
|
|
15
|
+
def second_chancers
|
|
16
|
+
@second_chancers ||= []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns the current ReplicationHelper; creates it if necessary
|
|
20
|
+
def helper
|
|
21
|
+
@helper ||= ReplicationHelper.new(self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the current replicator; creates it if necessary.
|
|
25
|
+
def replicator
|
|
26
|
+
@replicator ||=
|
|
27
|
+
Replicators.replicators[session.configuration.options[:replicator]].new(helper)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Calls the event filter for the give difference.
|
|
31
|
+
# * +diff+: instance of ReplicationDifference
|
|
32
|
+
# Returns +true+ if replication of the difference should *not* proceed.
|
|
33
|
+
def event_filtered?(diff)
|
|
34
|
+
event_filter = helper.options_for_table(diff.changes[:left].table)[:event_filter]
|
|
35
|
+
if event_filter && event_filter.respond_to?(:before_replicate)
|
|
36
|
+
not event_filter.before_replicate(
|
|
37
|
+
diff.changes[:left].table,
|
|
38
|
+
helper.type_cast(diff.changes[:left].table, diff.changes[:left].key),
|
|
39
|
+
helper,
|
|
40
|
+
diff
|
|
41
|
+
)
|
|
42
|
+
else
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns the next available ReplicationDifference.
|
|
48
|
+
# (Either new unprocessed differences or if not available, the first available 'second chancer'.)
|
|
49
|
+
#
|
|
50
|
+
def load_difference
|
|
51
|
+
@loaders ||= LoggedChangeLoaders.new(session)
|
|
52
|
+
@loaders.update # ensure the cache of change log records is up-to-date
|
|
53
|
+
diff = ReplicationDifference.new @loaders
|
|
54
|
+
diff.load
|
|
55
|
+
unless diff.loaded? or second_chancers.empty?
|
|
56
|
+
diff = second_chancers.shift
|
|
57
|
+
end
|
|
58
|
+
diff
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Executes the replication run.
|
|
62
|
+
def run
|
|
63
|
+
return unless [:left, :right].any? do |database|
|
|
64
|
+
changes_pending = false
|
|
65
|
+
t = Thread.new do
|
|
66
|
+
changes_pending = session.send(database).select_one(
|
|
67
|
+
"select id from #{session.configuration.options[:rep_prefix]}_pending_changes limit 1"
|
|
68
|
+
) != nil
|
|
69
|
+
end
|
|
70
|
+
t.join session.configuration.options[:database_connection_timeout]
|
|
71
|
+
changes_pending
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Apparently sometimes above check for changes takes already so long, that
|
|
75
|
+
# the replication run times out.
|
|
76
|
+
# Check for this and if timed out, return (silently).
|
|
77
|
+
return if sweeper.terminated?
|
|
78
|
+
|
|
79
|
+
success = false
|
|
80
|
+
begin
|
|
81
|
+
replicator # ensure that replicator is created and has chance to validate settings
|
|
82
|
+
|
|
83
|
+
loop do
|
|
84
|
+
begin
|
|
85
|
+
diff = load_difference
|
|
86
|
+
break unless diff.loaded?
|
|
87
|
+
break if sweeper.terminated?
|
|
88
|
+
if diff.type != :no_diff and not event_filtered?(diff)
|
|
89
|
+
replicator.replicate_difference diff
|
|
90
|
+
end
|
|
91
|
+
rescue Exception => e
|
|
92
|
+
if e.message =~ /violates foreign key constraint|foreign key constraint fails/i and !diff.second_chance?
|
|
93
|
+
# Note:
|
|
94
|
+
# Identifying the foreign key constraint violation via regular expression is
|
|
95
|
+
# database dependent and *dirty*.
|
|
96
|
+
# It would be better to use the ActiveRecord #translate_exception mechanism.
|
|
97
|
+
# However as per version 3.0.5 this doesn't work yet properly.
|
|
98
|
+
|
|
99
|
+
diff.second_chance = true
|
|
100
|
+
second_chancers << diff
|
|
101
|
+
else
|
|
102
|
+
begin
|
|
103
|
+
helper.log_replication_outcome diff, e.message,
|
|
104
|
+
e.class.to_s + "\n" + e.backtrace.join("\n")
|
|
105
|
+
rescue Exception => _
|
|
106
|
+
# if logging to database itself fails, re-raise the original exception
|
|
107
|
+
raise e
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
success = true
|
|
113
|
+
ensure
|
|
114
|
+
if sweeper.terminated?
|
|
115
|
+
helper.finalize false
|
|
116
|
+
session.disconnect_databases
|
|
117
|
+
else
|
|
118
|
+
helper.finalize success
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Installs the current sweeper into the database connections
|
|
124
|
+
def install_sweeper
|
|
125
|
+
[:left, :right].each do |database|
|
|
126
|
+
unless session.send(database).respond_to?(:sweeper)
|
|
127
|
+
session.send(database).send(:extend, NoisyConnection)
|
|
128
|
+
end
|
|
129
|
+
session.send(database).sweeper = sweeper
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Creates a new ReplicationRun instance.
|
|
134
|
+
# * +session+: the current Session
|
|
135
|
+
# * +sweeper+: the current TaskSweeper
|
|
136
|
+
def initialize(session, sweeper)
|
|
137
|
+
self.session = session
|
|
138
|
+
self.sweeper = sweeper
|
|
139
|
+
install_sweeper
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
$LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib'
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require 'thread'
|
|
5
|
+
require 'monitor'
|
|
6
|
+
|
|
7
|
+
class Monitor
|
|
8
|
+
alias lock mon_enter
|
|
9
|
+
alias unlock mon_exit
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module RR
|
|
13
|
+
# This class implements the functionality of the 'replicate' command.
|
|
14
|
+
class ReplicationRunner
|
|
15
|
+
|
|
16
|
+
CommandRunner.register 'replicate' => {
|
|
17
|
+
:command => self,
|
|
18
|
+
:description => 'Starts a replication process'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Provided options. Possible values:
|
|
22
|
+
# * +:config_file+: path to config file
|
|
23
|
+
attr_accessor :options
|
|
24
|
+
|
|
25
|
+
# Should be set to +true+ if the replication runner should be terminated.
|
|
26
|
+
attr_accessor :termination_requested
|
|
27
|
+
|
|
28
|
+
# Parses the given command line parameter array.
|
|
29
|
+
# Returns the status (as per UNIX conventions: 1 if parameters were invalid,
|
|
30
|
+
# 0 otherwise)
|
|
31
|
+
def process_options(args)
|
|
32
|
+
status = 0
|
|
33
|
+
self.options = {}
|
|
34
|
+
|
|
35
|
+
parser = OptionParser.new do |opts|
|
|
36
|
+
opts.banner = <<EOS
|
|
37
|
+
Usage: #{$0} replicate [options]
|
|
38
|
+
|
|
39
|
+
Replicates two databases as per specified configuration file.
|
|
40
|
+
EOS
|
|
41
|
+
opts.separator ""
|
|
42
|
+
opts.separator " Specific options:"
|
|
43
|
+
|
|
44
|
+
opts.on("-c", "--config", "=CONFIG_FILE",
|
|
45
|
+
"Mandatory. Path to configuration file.") do |arg|
|
|
46
|
+
options[:config_file] = arg
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
opts.on_tail("--help", "Show this message") do
|
|
50
|
+
$stderr.puts opts
|
|
51
|
+
self.options = nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
parser.parse!(args)
|
|
57
|
+
if options # this will be +nil+ if the --help option is specified
|
|
58
|
+
raise("Please specify configuration file") unless options.include?(:config_file)
|
|
59
|
+
end
|
|
60
|
+
rescue Exception => e
|
|
61
|
+
$stderr.puts "Command line parsing failed: #{e}"
|
|
62
|
+
$stderr.puts parser.help
|
|
63
|
+
self.options = nil
|
|
64
|
+
status = 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return status
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns the active +Session+.
|
|
71
|
+
# Loads config file and creates session if necessary.
|
|
72
|
+
def session
|
|
73
|
+
unless @session
|
|
74
|
+
unless @config
|
|
75
|
+
load options[:config_file]
|
|
76
|
+
@config = Initializer.configuration
|
|
77
|
+
end
|
|
78
|
+
@session = Session.new @config
|
|
79
|
+
end
|
|
80
|
+
@session
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Removes current +Session+.
|
|
84
|
+
def clear_session
|
|
85
|
+
@session = nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Wait for the next replication time
|
|
89
|
+
def pause_replication
|
|
90
|
+
@last_run ||= 1.year.ago
|
|
91
|
+
now = Time.now
|
|
92
|
+
@next_run = @last_run + session.configuration.options[:replication_interval]
|
|
93
|
+
unless now >= @next_run
|
|
94
|
+
waiting_time = @next_run - now
|
|
95
|
+
@waiter_thread.join waiting_time
|
|
96
|
+
end
|
|
97
|
+
@last_run = Time.now
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Initializes the waiter thread used for replication pauses and processing
|
|
101
|
+
# the process TERM signal.
|
|
102
|
+
def init_waiter
|
|
103
|
+
@termination_mutex = Monitor.new
|
|
104
|
+
@termination_mutex.lock
|
|
105
|
+
@waiter_thread ||= Thread.new {@termination_mutex.lock; self.termination_requested = true}
|
|
106
|
+
%w(TERM INT).each do |signal|
|
|
107
|
+
Signal.trap(signal) {puts "\nCaught '#{signal}': Initiating graceful shutdown"; @termination_mutex.unlock}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Prepares the replication
|
|
112
|
+
def prepare_replication
|
|
113
|
+
initializer = ReplicationInitializer.new session
|
|
114
|
+
initializer.prepare_replication
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Executes a single replication run
|
|
118
|
+
def execute_once
|
|
119
|
+
session.refresh
|
|
120
|
+
timeout = session.configuration.options[:database_connection_timeout]
|
|
121
|
+
terminated = TaskSweeper.timeout(timeout) do |sweeper|
|
|
122
|
+
run = ReplicationRun.new session, sweeper
|
|
123
|
+
run.run
|
|
124
|
+
end.terminated?
|
|
125
|
+
raise "replication run timed out" if terminated
|
|
126
|
+
rescue Exception => e
|
|
127
|
+
clear_session
|
|
128
|
+
raise e
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Executes an endless loop of replication runs
|
|
132
|
+
def execute
|
|
133
|
+
init_waiter
|
|
134
|
+
prepare_replication
|
|
135
|
+
|
|
136
|
+
until termination_requested do
|
|
137
|
+
begin
|
|
138
|
+
execute_once
|
|
139
|
+
rescue Exception => e
|
|
140
|
+
now = Time.now.iso8601
|
|
141
|
+
$stderr.puts "#{now} Exception caught: #{e}"
|
|
142
|
+
if @last_exception_message != e.to_s # only print backtrace if something changed
|
|
143
|
+
@last_exception_message = e.to_s
|
|
144
|
+
$stderr.puts e.backtrace.map {|line| line.gsub(/^/, "#{' ' * now.length} ")}
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
pause_replication
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Entry points for executing a processing run.
|
|
152
|
+
# args: the array of command line options that were provided by the user.
|
|
153
|
+
def self.run(args)
|
|
154
|
+
runner = new
|
|
155
|
+
|
|
156
|
+
status = runner.process_options(args)
|
|
157
|
+
if runner.options
|
|
158
|
+
runner.execute
|
|
159
|
+
end
|
|
160
|
+
status
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module RR
|
|
2
|
+
# Replicators are classes that implement the replication policies.
|
|
3
|
+
# This module provides functionality to register replicators and access the
|
|
4
|
+
# list of registered replicators.
|
|
5
|
+
# Each Replicator must register itself with Replicators#register.
|
|
6
|
+
# Each Replicator must implement the following methods:
|
|
7
|
+
#
|
|
8
|
+
# # Creates a new replicator (A replicator is used for one replication run only)
|
|
9
|
+
# # * sync_helper: a SyncHelper object providing necessary information and functionalities
|
|
10
|
+
# def initialize(sync_helper)
|
|
11
|
+
#
|
|
12
|
+
# # Called to sync the provided difference.
|
|
13
|
+
# # +difference+ is an instance of +ReplicationDifference+
|
|
14
|
+
# def replicate_difference(difference)
|
|
15
|
+
#
|
|
16
|
+
# # Provides default option for the replicator. Optional.
|
|
17
|
+
# # Returns a hash with :key => value pairs.
|
|
18
|
+
# def self.default_options
|
|
19
|
+
module Replicators
|
|
20
|
+
# Returns a Hash of currently registered replicators.
|
|
21
|
+
# (Empty Hash if no replicators were defined.)
|
|
22
|
+
def self.replicators
|
|
23
|
+
@replicators ||= {}
|
|
24
|
+
@replicators
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns the correct replicator class as per provided options hash
|
|
28
|
+
def self.configured_replicator(options)
|
|
29
|
+
replicators[options[:replicator]]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Registers one or multiple replicators.
|
|
33
|
+
# syncer_hash is a Hash with
|
|
34
|
+
# key:: The adapter symbol as used to reference the replicator
|
|
35
|
+
# value:: The class implementing the replicator
|
|
36
|
+
def self.register(replicator_hash)
|
|
37
|
+
@replicators ||= {}
|
|
38
|
+
@replicators.merge! replicator_hash
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
module RR
|
|
2
|
+
module Replicators
|
|
3
|
+
# This replicator implements a two way replication.
|
|
4
|
+
# Options:
|
|
5
|
+
# * :+left_change_handling+, :+right_change_handling+:
|
|
6
|
+
# Handling of records that were changed only in the named database.
|
|
7
|
+
# Can be any of the following:
|
|
8
|
+
# * :+ignore+: No action.
|
|
9
|
+
# * :+replicate+: Updates other database accordingly. *Default* *Setting*
|
|
10
|
+
# * +Proc+ object:
|
|
11
|
+
# If a Proc object is given, it is responsible for dealing with the
|
|
12
|
+
# record. Called with the following parameters:
|
|
13
|
+
# * replication_helper: The current ReplicationHelper instance.
|
|
14
|
+
# * difference: A ReplicationDifference instance describing the change
|
|
15
|
+
# * :+replication_conflict_handling+:
|
|
16
|
+
# Handling of conflicting record changes. Can be any of the following:
|
|
17
|
+
# * :+ignore+: No action. *Default* *Setting*
|
|
18
|
+
# * :+left_wins+: The right database is updated accordingly.
|
|
19
|
+
# * :+right_wins+: The left database is updated accordingly.
|
|
20
|
+
# * :+later_wins+:
|
|
21
|
+
# The more recent change is replicated.
|
|
22
|
+
# (If both changes have same age: left change is replicated)
|
|
23
|
+
# * :+earlier_wins+:
|
|
24
|
+
# The less recent change is replicated.
|
|
25
|
+
# (If both records have same age: left change is replicated)
|
|
26
|
+
# * +Proc+ object:
|
|
27
|
+
# If a Proc object is given, it is responsible for dealing with the
|
|
28
|
+
# record. Called with the following parameters:
|
|
29
|
+
# * replication_helper: The current ReplicationHelper instance.
|
|
30
|
+
# * difference: A ReplicationDifference instance describing the changes
|
|
31
|
+
# * :+logged_replication_events+:
|
|
32
|
+
# Specifies which types of replications are logged.
|
|
33
|
+
# Is either a single value or an array of multiple ones.
|
|
34
|
+
# Default: [:ignored_conflicts]
|
|
35
|
+
# Possible values:
|
|
36
|
+
# * :+ignored_changes+: log ignored (but not replicated) non-conflict changes
|
|
37
|
+
# * :+all_changes+: log all non-conflict changes
|
|
38
|
+
# * :+ignored_conflicts+: log ignored (but not replicated) conflicts
|
|
39
|
+
# * :+all_conflicts+: log all conflicts
|
|
40
|
+
#
|
|
41
|
+
# Example of using a Proc object for custom behaviour:
|
|
42
|
+
# lambda do |rep_helper, diff|
|
|
43
|
+
# # if specified as replication_conflict_handling option, logs all
|
|
44
|
+
# # conflicts to a text file
|
|
45
|
+
# File.open('/var/log/rubyrep_conflict_log', 'a') do |f|
|
|
46
|
+
# f.puts <<-end_str
|
|
47
|
+
# #{Time.now}: conflict
|
|
48
|
+
# * in table #{diff.changes[:left].table}
|
|
49
|
+
# * for record '#{diff.changes[:left].key}'
|
|
50
|
+
# * change type in left db: '#{diff.changes[:left].type}'
|
|
51
|
+
# * change type in right db: '#{diff.changes[:right].type}'
|
|
52
|
+
# end_str
|
|
53
|
+
# end
|
|
54
|
+
# end
|
|
55
|
+
class TwoWayReplicator
|
|
56
|
+
|
|
57
|
+
# Register the syncer
|
|
58
|
+
Replicators.register :two_way => self
|
|
59
|
+
|
|
60
|
+
# The current ReplicationHelper object
|
|
61
|
+
attr_accessor :rep_helper
|
|
62
|
+
|
|
63
|
+
# Provides default option for the replicator. Optional.
|
|
64
|
+
# Returns a hash with key => value pairs.
|
|
65
|
+
def self.default_options
|
|
66
|
+
{
|
|
67
|
+
:left_change_handling => :replicate,
|
|
68
|
+
:right_change_handling => :replicate,
|
|
69
|
+
:replication_conflict_handling => :ignore,
|
|
70
|
+
:logged_replication_events => [:ignored_conflicts],
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Checks if an option is configured correctly. Raises an ArgumentError if not.
|
|
75
|
+
# * +table_spec+: the table specification to which the option belongs. May be +nil+.
|
|
76
|
+
# * +valid_option_values+: array of valid option values
|
|
77
|
+
# * +option_key+: the key of the option that is to be checked
|
|
78
|
+
# * +option_value+: the value of the option that is to be checked
|
|
79
|
+
def verify_option(table_spec, valid_option_values, option_key, option_value)
|
|
80
|
+
unless valid_option_values.include? option_value
|
|
81
|
+
message = ""
|
|
82
|
+
message << "#{table_spec.inspect}: " if table_spec
|
|
83
|
+
message << "#{option_value.inspect} not a valid #{option_key.inspect} option"
|
|
84
|
+
raise ArgumentError.new(message)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Verifies if the :+left_change_handling+ / :+right_change_handling+
|
|
89
|
+
# options are valid.
|
|
90
|
+
# Raises an ArgumentError if an option is invalid
|
|
91
|
+
def validate_change_handling_options
|
|
92
|
+
[:left_change_handling, :right_change_handling].each do |key|
|
|
93
|
+
rep_helper.session.configuration.each_matching_option(key) do |table_spec, value|
|
|
94
|
+
unless value.respond_to? :call
|
|
95
|
+
verify_option table_spec, [:ignore, :replicate], key, value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Verifies if the given :+replication_conflict_handling+ options are valid.
|
|
102
|
+
# Raises an ArgumentError if an option is invalid.
|
|
103
|
+
def validate_conflict_handling_options
|
|
104
|
+
rep_helper.session.configuration.each_matching_option(:replication_conflict_handling) do |table_spec, value|
|
|
105
|
+
unless value.respond_to? :call
|
|
106
|
+
verify_option table_spec,
|
|
107
|
+
[:ignore, :left_wins, :right_wins, :later_wins, :earlier_wins],
|
|
108
|
+
:replication_conflict_handling, value
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Verifies if the given :+replication_logging+ option /options is / are valid.
|
|
114
|
+
# Raises an ArgumentError if invalid
|
|
115
|
+
def validate_logging_options
|
|
116
|
+
rep_helper.session.configuration.each_matching_option(:logged_replication_events) do |table_spec, values|
|
|
117
|
+
values = [values].flatten # ensure that I have an array
|
|
118
|
+
values.each do |value|
|
|
119
|
+
verify_option table_spec,
|
|
120
|
+
[:ignored_changes, :all_changes, :ignored_conflicts, :all_conflicts],
|
|
121
|
+
:logged_replication_events, value
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Initializes the TwoWayReplicator
|
|
127
|
+
# Raises an ArgumentError if any of the replication options is invalid.
|
|
128
|
+
#
|
|
129
|
+
# Parameters:
|
|
130
|
+
# * rep_helper:
|
|
131
|
+
# The ReplicationHelper object providing information and utility functions.
|
|
132
|
+
def initialize(rep_helper)
|
|
133
|
+
self.rep_helper = rep_helper
|
|
134
|
+
|
|
135
|
+
validate_change_handling_options
|
|
136
|
+
validate_conflict_handling_options
|
|
137
|
+
validate_logging_options
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Shortcut to calculate the "other" database.
|
|
141
|
+
OTHER_SIDE = {
|
|
142
|
+
:left => :right,
|
|
143
|
+
:right => :left
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Specifies how to clear conflicts.
|
|
147
|
+
# The outer hash keys describe the type of the winning change.
|
|
148
|
+
# The inner hash keys describe the type of the loosing change.
|
|
149
|
+
# The inser hash values describe the action to take on the loosing side.
|
|
150
|
+
CONFLICT_STATE_MATRIX = {
|
|
151
|
+
:insert => {:insert => :update, :update => :update, :delete => :insert},
|
|
152
|
+
:update => {:insert => :update, :update => :update, :delete => :insert},
|
|
153
|
+
:delete => {:insert => :delete, :update => :delete, :delete => :delete}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Helper function that clears a conflict by taking the change from the
|
|
157
|
+
# specified winning database and updating the other database accordingly.
|
|
158
|
+
# * +source_db+: the winning database (either :+left+ or :+right+)
|
|
159
|
+
# * +diff+: the ReplicationDifference instance
|
|
160
|
+
# * +remaining_attempts+: the number of remaining replication attempts for this difference
|
|
161
|
+
def clear_conflict(source_db, diff, remaining_attempts)
|
|
162
|
+
source_change = diff.changes[source_db]
|
|
163
|
+
target_db = OTHER_SIDE[source_db]
|
|
164
|
+
target_change = diff.changes[target_db]
|
|
165
|
+
|
|
166
|
+
target_action = CONFLICT_STATE_MATRIX[source_change.type][target_change.type]
|
|
167
|
+
source_key = source_change.type == :update ? source_change.new_key : source_change.key
|
|
168
|
+
target_key = target_change.type == :update ? target_change.new_key : target_change.key
|
|
169
|
+
case target_action
|
|
170
|
+
when :insert
|
|
171
|
+
attempt_insert source_db, diff, remaining_attempts, source_key
|
|
172
|
+
when :update
|
|
173
|
+
attempt_update source_db, diff, remaining_attempts, source_key, target_key
|
|
174
|
+
when :delete
|
|
175
|
+
attempt_delete source_db, diff, remaining_attempts, target_key
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Logs replication of the specified difference as per configured
|
|
180
|
+
# :+replication_conflict_logging+ / :+left_change_logging+ / :+right_change_logging+ options.
|
|
181
|
+
# * +winner+: Either the winner database (:+left+ or :+right+) or :+ignore+
|
|
182
|
+
# * +diff+: the ReplicationDifference instance
|
|
183
|
+
def log_replication_outcome(winner, diff)
|
|
184
|
+
options = rep_helper.options_for_table(diff.changes[:left].table)
|
|
185
|
+
option_values = [options[:logged_replication_events]].flatten # make sure I have an array
|
|
186
|
+
if diff.type == :conflict
|
|
187
|
+
return unless option_values.include?(:all_conflicts) or option_values.include?(:ignored_conflicts)
|
|
188
|
+
return if winner != :ignore and not option_values.include?(:all_conflicts)
|
|
189
|
+
outcome = {:left => 'left_won', :right => 'right_won', :ignore => 'ignored'}[winner]
|
|
190
|
+
else
|
|
191
|
+
return unless option_values.include?(:all_changes) or option_values.include?(:ignored_changes)
|
|
192
|
+
return if winner != :ignore and not option_values.include?(:all_changes)
|
|
193
|
+
outcome = winner == :ignore ? 'ignored' : 'replicated'
|
|
194
|
+
end
|
|
195
|
+
rep_helper.log_replication_outcome diff, outcome
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# How often a replication will be attempted (in case it fails because the
|
|
199
|
+
# record in question was removed from the source or inserted into the
|
|
200
|
+
# target database _after_ the ReplicationDifference was loaded
|
|
201
|
+
MAX_REPLICATION_ATTEMPTS = 2
|
|
202
|
+
|
|
203
|
+
# Attempts to read the specified record from the source database and insert
|
|
204
|
+
# it into the target database.
|
|
205
|
+
# Retries if insert fails due to missing source or suddenly existing target
|
|
206
|
+
# record.
|
|
207
|
+
# * +source_db+: either :+left+ or :+right+ - source database of replication
|
|
208
|
+
# * +diff+: the current ReplicationDifference instance
|
|
209
|
+
# * +remaining_attempts+: the number of remaining replication attempts for this difference
|
|
210
|
+
# * +source_key+: a column_name => value hash identifying the source record
|
|
211
|
+
def attempt_insert(source_db, diff, remaining_attempts, source_key)
|
|
212
|
+
source_change = diff.changes[source_db]
|
|
213
|
+
source_table = source_change.table
|
|
214
|
+
target_db = OTHER_SIDE[source_db]
|
|
215
|
+
target_table = rep_helper.corresponding_table(source_db, source_table)
|
|
216
|
+
|
|
217
|
+
values = rep_helper.load_record source_db, source_table, source_key
|
|
218
|
+
if values == nil
|
|
219
|
+
diff.amend
|
|
220
|
+
replicate_difference diff, remaining_attempts - 1, "source record for insert vanished"
|
|
221
|
+
else
|
|
222
|
+
attempt_change('insert', source_db, target_db, diff, remaining_attempts) do
|
|
223
|
+
rep_helper.insert_record target_db, target_table, values
|
|
224
|
+
log_replication_outcome source_db, diff
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Attempts to read the specified record from the source database and update
|
|
230
|
+
# the specified record in the target database.
|
|
231
|
+
# Retries if update fails due to missing source
|
|
232
|
+
# * +source_db+: either :+left+ or :+right+ - source database of replication
|
|
233
|
+
# * +diff+: the current ReplicationDifference instance
|
|
234
|
+
# * +remaining_attempts+: the number of remaining replication attempts for this difference
|
|
235
|
+
# * +source_key+: a column_name => value hash identifying the source record
|
|
236
|
+
# * +target_key+: a column_name => value hash identifying the source record
|
|
237
|
+
def attempt_update(source_db, diff, remaining_attempts, source_key, target_key)
|
|
238
|
+
source_change = diff.changes[source_db]
|
|
239
|
+
source_table = source_change.table
|
|
240
|
+
target_db = OTHER_SIDE[source_db]
|
|
241
|
+
target_table = rep_helper.corresponding_table(source_db, source_table)
|
|
242
|
+
|
|
243
|
+
values = rep_helper.load_record source_db, source_table, source_key
|
|
244
|
+
if values == nil
|
|
245
|
+
diff.amend
|
|
246
|
+
replicate_difference diff, remaining_attempts - 1, "source record for update vanished"
|
|
247
|
+
else
|
|
248
|
+
attempt_change('update', source_db, target_db, diff, remaining_attempts) do
|
|
249
|
+
number_updated = rep_helper.update_record target_db, target_table, values, target_key
|
|
250
|
+
if number_updated == 0
|
|
251
|
+
diff.amend
|
|
252
|
+
replicate_difference diff, remaining_attempts - 1, "target record for update vanished"
|
|
253
|
+
else
|
|
254
|
+
log_replication_outcome source_db, diff
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Helper for execution of insert / update / delete attempts.
|
|
261
|
+
# Wraps those attempts into savepoints and handles exceptions.
|
|
262
|
+
#
|
|
263
|
+
# Note:
|
|
264
|
+
# Savepoints have to be used for PostgreSQL (as a failed SQL statement
|
|
265
|
+
# will otherwise invalidate the complete transaction.)
|
|
266
|
+
#
|
|
267
|
+
# * +action+: short description of change (e. g.: "update" or "delete")
|
|
268
|
+
# * +source_db+: either :+left+ or :+right+ - source database of replication
|
|
269
|
+
# * +target_db+: either :+left+ or :+right+ - target database of replication
|
|
270
|
+
# * +diff+: the current ReplicationDifference instance
|
|
271
|
+
# * +remaining_attempts+: the number of remaining replication attempts for this difference
|
|
272
|
+
def attempt_change(action, source_db, target_db, diff, remaining_attempts)
|
|
273
|
+
begin
|
|
274
|
+
rep_helper.session.send(target_db).execute "savepoint rr_#{action}_#{remaining_attempts}"
|
|
275
|
+
yield
|
|
276
|
+
unless rep_helper.new_transaction?
|
|
277
|
+
rep_helper.session.send(target_db).execute "release savepoint rr_#{action}_#{remaining_attempts}"
|
|
278
|
+
end
|
|
279
|
+
rescue Exception => e
|
|
280
|
+
rep_helper.session.send(target_db).execute "rollback to savepoint rr_#{action}_#{remaining_attempts}"
|
|
281
|
+
diff.amend
|
|
282
|
+
replicate_difference diff, remaining_attempts - 1,
|
|
283
|
+
"#{action} failed with #{e.message}"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Attempts to delete the source record from the target database.
|
|
288
|
+
# E. g. if +source_db is :+left+, then the record is deleted in database
|
|
289
|
+
# :+right+.
|
|
290
|
+
# * +source_db+: either :+left+ or :+right+ - source database of replication
|
|
291
|
+
# * +diff+: the current ReplicationDifference instance
|
|
292
|
+
# * +remaining_attempts+: the number of remaining replication attempts for this difference
|
|
293
|
+
# * +target_key+: a column_name => value hash identifying the source record
|
|
294
|
+
def attempt_delete(source_db, diff, remaining_attempts, target_key)
|
|
295
|
+
change = diff.changes[source_db]
|
|
296
|
+
target_db = OTHER_SIDE[source_db]
|
|
297
|
+
target_table = rep_helper.corresponding_table(source_db, change.table)
|
|
298
|
+
|
|
299
|
+
attempt_change('delete', source_db, target_db, diff, remaining_attempts) do
|
|
300
|
+
number_updated = rep_helper.delete_record target_db, target_table, target_key
|
|
301
|
+
if number_updated == 0
|
|
302
|
+
diff.amend
|
|
303
|
+
replicate_difference diff, remaining_attempts - 1, "target record for delete vanished"
|
|
304
|
+
else
|
|
305
|
+
log_replication_outcome source_db, diff
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Called to replicate the specified difference.
|
|
311
|
+
# * :+diff+: ReplicationDifference instance
|
|
312
|
+
# * :+remaining_attempts+: how many more times a replication will be attempted
|
|
313
|
+
# * :+previous_failure_description+: why the previous replication attempt failed
|
|
314
|
+
def replicate_difference(diff, remaining_attempts = MAX_REPLICATION_ATTEMPTS, previous_failure_description = nil)
|
|
315
|
+
raise Exception, previous_failure_description || "max replication attempts exceeded" if remaining_attempts == 0
|
|
316
|
+
options = rep_helper.options_for_table(diff.changes[:left].table)
|
|
317
|
+
if diff.type == :left or diff.type == :right
|
|
318
|
+
key = diff.type == :left ? :left_change_handling : :right_change_handling
|
|
319
|
+
option = options[key]
|
|
320
|
+
|
|
321
|
+
if option == :ignore
|
|
322
|
+
log_replication_outcome :ignore, diff
|
|
323
|
+
elsif option == :replicate
|
|
324
|
+
source_db = diff.type
|
|
325
|
+
|
|
326
|
+
change = diff.changes[source_db]
|
|
327
|
+
|
|
328
|
+
case change.type
|
|
329
|
+
when :insert
|
|
330
|
+
attempt_insert source_db, diff, remaining_attempts, change.key
|
|
331
|
+
when :update
|
|
332
|
+
attempt_update source_db, diff, remaining_attempts, change.new_key, change.key
|
|
333
|
+
when :delete
|
|
334
|
+
attempt_delete source_db, diff, remaining_attempts, change.key
|
|
335
|
+
end
|
|
336
|
+
else # option must be a Proc
|
|
337
|
+
option.call rep_helper, diff
|
|
338
|
+
end
|
|
339
|
+
elsif diff.type == :conflict
|
|
340
|
+
option = options[:replication_conflict_handling]
|
|
341
|
+
if option == :ignore
|
|
342
|
+
log_replication_outcome :ignore, diff
|
|
343
|
+
elsif option == :left_wins
|
|
344
|
+
clear_conflict :left, diff, remaining_attempts
|
|
345
|
+
elsif option == :right_wins
|
|
346
|
+
clear_conflict :right, diff, remaining_attempts
|
|
347
|
+
elsif option == :later_wins
|
|
348
|
+
winner_db = diff.changes[:left].last_changed_at >= diff.changes[:right].last_changed_at ? :left : :right
|
|
349
|
+
clear_conflict winner_db, diff, remaining_attempts
|
|
350
|
+
elsif option == :earlier_wins
|
|
351
|
+
winner_db = diff.changes[:left].last_changed_at <= diff.changes[:right].last_changed_at ? :left : :right
|
|
352
|
+
clear_conflict winner_db, diff, remaining_attempts
|
|
353
|
+
else # option must be a Proc
|
|
354
|
+
option.call rep_helper, diff
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|