andyjeffries-rubyrep 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (125) hide show
  1. data/History.txt +83 -0
  2. data/License.txt +20 -0
  3. data/Manifest.txt +151 -0
  4. data/README.txt +37 -0
  5. data/bin/rubyrep +8 -0
  6. data/lib/rubyrep.rb +72 -0
  7. data/lib/rubyrep/base_runner.rb +195 -0
  8. data/lib/rubyrep/command_runner.rb +144 -0
  9. data/lib/rubyrep/committers/buffered_committer.rb +151 -0
  10. data/lib/rubyrep/committers/committers.rb +152 -0
  11. data/lib/rubyrep/configuration.rb +275 -0
  12. data/lib/rubyrep/connection_extenders/connection_extenders.rb +165 -0
  13. data/lib/rubyrep/connection_extenders/jdbc_extender.rb +65 -0
  14. data/lib/rubyrep/connection_extenders/mysql_extender.rb +59 -0
  15. data/lib/rubyrep/connection_extenders/postgresql_extender.rb +277 -0
  16. data/lib/rubyrep/database_proxy.rb +52 -0
  17. data/lib/rubyrep/direct_table_scan.rb +75 -0
  18. data/lib/rubyrep/generate_runner.rb +105 -0
  19. data/lib/rubyrep/initializer.rb +39 -0
  20. data/lib/rubyrep/log_helper.rb +30 -0
  21. data/lib/rubyrep/logged_change.rb +160 -0
  22. data/lib/rubyrep/logged_change_loader.rb +197 -0
  23. data/lib/rubyrep/noisy_connection.rb +80 -0
  24. data/lib/rubyrep/proxied_table_scan.rb +171 -0
  25. data/lib/rubyrep/proxy_block_cursor.rb +145 -0
  26. data/lib/rubyrep/proxy_connection.rb +431 -0
  27. data/lib/rubyrep/proxy_cursor.rb +44 -0
  28. data/lib/rubyrep/proxy_row_cursor.rb +43 -0
  29. data/lib/rubyrep/proxy_runner.rb +89 -0
  30. data/lib/rubyrep/replication_difference.rb +100 -0
  31. data/lib/rubyrep/replication_extenders/mysql_replication.rb +271 -0
  32. data/lib/rubyrep/replication_extenders/postgresql_replication.rb +236 -0
  33. data/lib/rubyrep/replication_extenders/replication_extenders.rb +26 -0
  34. data/lib/rubyrep/replication_helper.rb +142 -0
  35. data/lib/rubyrep/replication_initializer.rb +327 -0
  36. data/lib/rubyrep/replication_run.rb +142 -0
  37. data/lib/rubyrep/replication_runner.rb +166 -0
  38. data/lib/rubyrep/replicators/replicators.rb +42 -0
  39. data/lib/rubyrep/replicators/two_way_replicator.rb +361 -0
  40. data/lib/rubyrep/scan_progress_printers/progress_bar.rb +65 -0
  41. data/lib/rubyrep/scan_progress_printers/scan_progress_printers.rb +65 -0
  42. data/lib/rubyrep/scan_report_printers/scan_detail_reporter.rb +111 -0
  43. data/lib/rubyrep/scan_report_printers/scan_report_printers.rb +67 -0
  44. data/lib/rubyrep/scan_report_printers/scan_summary_reporter.rb +75 -0
  45. data/lib/rubyrep/scan_runner.rb +25 -0
  46. data/lib/rubyrep/session.rb +230 -0
  47. data/lib/rubyrep/sync_helper.rb +121 -0
  48. data/lib/rubyrep/sync_runner.rb +31 -0
  49. data/lib/rubyrep/syncers/syncers.rb +112 -0
  50. data/lib/rubyrep/syncers/two_way_syncer.rb +174 -0
  51. data/lib/rubyrep/table_scan.rb +54 -0
  52. data/lib/rubyrep/table_scan_helper.rb +46 -0
  53. data/lib/rubyrep/table_sorter.rb +70 -0
  54. data/lib/rubyrep/table_spec_resolver.rb +142 -0
  55. data/lib/rubyrep/table_sync.rb +90 -0
  56. data/lib/rubyrep/task_sweeper.rb +77 -0
  57. data/lib/rubyrep/trigger_mode_switcher.rb +63 -0
  58. data/lib/rubyrep/type_casting_cursor.rb +31 -0
  59. data/lib/rubyrep/uninstall_runner.rb +93 -0
  60. data/lib/rubyrep/version.rb +9 -0
  61. data/rubyrep +8 -0
  62. data/rubyrep.bat +4 -0
  63. data/setup.rb +1585 -0
  64. data/spec/base_runner_spec.rb +218 -0
  65. data/spec/buffered_committer_spec.rb +274 -0
  66. data/spec/command_runner_spec.rb +145 -0
  67. data/spec/committers_spec.rb +178 -0
  68. data/spec/configuration_spec.rb +203 -0
  69. data/spec/connection_extender_interface_spec.rb +141 -0
  70. data/spec/connection_extenders_registration_spec.rb +164 -0
  71. data/spec/database_proxy_spec.rb +48 -0
  72. data/spec/database_rake_spec.rb +40 -0
  73. data/spec/db_specific_connection_extenders_spec.rb +34 -0
  74. data/spec/db_specific_replication_extenders_spec.rb +38 -0
  75. data/spec/direct_table_scan_spec.rb +61 -0
  76. data/spec/dolphins.jpg +0 -0
  77. data/spec/generate_runner_spec.rb +84 -0
  78. data/spec/initializer_spec.rb +46 -0
  79. data/spec/log_helper_spec.rb +39 -0
  80. data/spec/logged_change_loader_spec.rb +68 -0
  81. data/spec/logged_change_spec.rb +470 -0
  82. data/spec/noisy_connection_spec.rb +78 -0
  83. data/spec/postgresql_replication_spec.rb +48 -0
  84. data/spec/postgresql_schema_support_spec.rb +212 -0
  85. data/spec/postgresql_support_spec.rb +63 -0
  86. data/spec/progress_bar_spec.rb +77 -0
  87. data/spec/proxied_table_scan_spec.rb +151 -0
  88. data/spec/proxy_block_cursor_spec.rb +197 -0
  89. data/spec/proxy_connection_spec.rb +423 -0
  90. data/spec/proxy_cursor_spec.rb +56 -0
  91. data/spec/proxy_row_cursor_spec.rb +66 -0
  92. data/spec/proxy_runner_spec.rb +70 -0
  93. data/spec/replication_difference_spec.rb +161 -0
  94. data/spec/replication_extender_interface_spec.rb +367 -0
  95. data/spec/replication_extenders_spec.rb +32 -0
  96. data/spec/replication_helper_spec.rb +178 -0
  97. data/spec/replication_initializer_spec.rb +509 -0
  98. data/spec/replication_run_spec.rb +443 -0
  99. data/spec/replication_runner_spec.rb +254 -0
  100. data/spec/replicators_spec.rb +36 -0
  101. data/spec/rubyrep_spec.rb +8 -0
  102. data/spec/scan_detail_reporter_spec.rb +119 -0
  103. data/spec/scan_progress_printers_spec.rb +68 -0
  104. data/spec/scan_report_printers_spec.rb +67 -0
  105. data/spec/scan_runner_spec.rb +50 -0
  106. data/spec/scan_summary_reporter_spec.rb +61 -0
  107. data/spec/session_spec.rb +253 -0
  108. data/spec/spec.opts +1 -0
  109. data/spec/spec_helper.rb +305 -0
  110. data/spec/strange_name_support_spec.rb +135 -0
  111. data/spec/sync_helper_spec.rb +169 -0
  112. data/spec/sync_runner_spec.rb +78 -0
  113. data/spec/syncers_spec.rb +171 -0
  114. data/spec/table_scan_helper_spec.rb +36 -0
  115. data/spec/table_scan_spec.rb +49 -0
  116. data/spec/table_sorter_spec.rb +30 -0
  117. data/spec/table_spec_resolver_spec.rb +111 -0
  118. data/spec/table_sync_spec.rb +140 -0
  119. data/spec/task_sweeper_spec.rb +47 -0
  120. data/spec/trigger_mode_switcher_spec.rb +83 -0
  121. data/spec/two_way_replicator_spec.rb +721 -0
  122. data/spec/two_way_syncer_spec.rb +256 -0
  123. data/spec/type_casting_cursor_spec.rb +50 -0
  124. data/spec/uninstall_runner_spec.rb +93 -0
  125. 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