makara 0.3.8 → 0.5.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 (64) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/gem-publish-public.yml +36 -0
  3. data/.travis.yml +71 -9
  4. data/CHANGELOG.md +84 -25
  5. data/Gemfile +4 -3
  6. data/README.md +37 -34
  7. data/gemfiles/ar-head.gemfile +9 -0
  8. data/gemfiles/ar30.gemfile +7 -1
  9. data/gemfiles/ar31.gemfile +8 -1
  10. data/gemfiles/ar32.gemfile +8 -1
  11. data/gemfiles/ar40.gemfile +10 -1
  12. data/gemfiles/ar41.gemfile +10 -1
  13. data/gemfiles/ar42.gemfile +10 -1
  14. data/gemfiles/ar50.gemfile +11 -2
  15. data/gemfiles/ar51.gemfile +11 -2
  16. data/gemfiles/ar52.gemfile +24 -0
  17. data/gemfiles/ar60.gemfile +24 -0
  18. data/lib/active_record/connection_adapters/makara_abstract_adapter.rb +109 -3
  19. data/lib/active_record/connection_adapters/makara_postgis_adapter.rb +41 -0
  20. data/lib/makara.rb +15 -4
  21. data/lib/makara/cache.rb +4 -40
  22. data/lib/makara/config_parser.rb +14 -3
  23. data/lib/makara/connection_wrapper.rb +26 -2
  24. data/lib/makara/context.rb +108 -38
  25. data/lib/makara/cookie.rb +52 -0
  26. data/lib/makara/error_handler.rb +2 -2
  27. data/lib/makara/errors/blacklisted_while_in_transaction.rb +14 -0
  28. data/lib/makara/errors/invalid_shard.rb +16 -0
  29. data/lib/makara/logging/logger.rb +1 -1
  30. data/lib/makara/middleware.rb +12 -75
  31. data/lib/makara/pool.rb +53 -40
  32. data/lib/makara/proxy.rb +52 -30
  33. data/lib/makara/railtie.rb +0 -6
  34. data/lib/makara/strategies/round_robin.rb +6 -0
  35. data/lib/makara/strategies/shard_aware.rb +47 -0
  36. data/lib/makara/version.rb +2 -2
  37. data/makara.gemspec +5 -1
  38. data/spec/active_record/connection_adapters/makara_abstract_adapter_spec.rb +10 -5
  39. data/spec/active_record/connection_adapters/makara_mysql2_adapter_spec.rb +17 -2
  40. data/spec/active_record/connection_adapters/makara_postgis_adapter_spec.rb +155 -0
  41. data/spec/active_record/connection_adapters/makara_postgresql_adapter_spec.rb +76 -3
  42. data/spec/cache_spec.rb +2 -52
  43. data/spec/config_parser_spec.rb +27 -13
  44. data/spec/connection_wrapper_spec.rb +5 -2
  45. data/spec/context_spec.rb +163 -100
  46. data/spec/cookie_spec.rb +72 -0
  47. data/spec/middleware_spec.rb +26 -55
  48. data/spec/pool_spec.rb +24 -0
  49. data/spec/proxy_spec.rb +51 -36
  50. data/spec/spec_helper.rb +5 -9
  51. data/spec/strategies/shard_aware_spec.rb +219 -0
  52. data/spec/support/helpers.rb +6 -2
  53. data/spec/support/mock_objects.rb +5 -1
  54. data/spec/support/mysql2_database.yml +1 -0
  55. data/spec/support/mysql2_database_with_custom_errors.yml +5 -0
  56. data/spec/support/postgis_database.yml +15 -0
  57. data/spec/support/postgis_schema.rb +11 -0
  58. data/spec/support/postgresql_database.yml +2 -0
  59. data/spec/support/proxy_extensions.rb +1 -1
  60. data/spec/support/schema.rb +5 -5
  61. data/spec/support/user.rb +5 -0
  62. metadata +28 -9
  63. data/lib/makara/cache/memory_store.rb +0 -28
  64. data/lib/makara/cache/noop_store.rb +0 -15
@@ -1,6 +1,7 @@
1
1
  require 'digest/md5'
2
2
  require 'active_support/core_ext/hash/keys'
3
3
  require 'active_support/core_ext/hash/except'
4
+ require 'cgi'
4
5
 
5
6
  # Convenience methods to grab subconfigs out of the primary configuration.
6
7
  # Provides a way to generate a consistent ID based on a unique config.
@@ -63,7 +64,7 @@ module Makara
63
64
  # Converts the given URL to a full connection hash.
64
65
  def to_hash
65
66
  config = raw_config.reject { |_,value| value.blank? }
66
- config.map { |key,value| config[key] = URI.unescape(value) if value.is_a? String }
67
+ config.map { |key,value| config[key] = CGI.unescape(value) if value.is_a? String }
67
68
  config
68
69
  end
69
70
 
@@ -127,7 +128,7 @@ module Makara
127
128
  # NOTE: Does not use ENV['DATABASE_URL']
128
129
  def self.merge_and_resolve_default_url_config(config)
129
130
  if ENV['DATABASE_URL']
130
- Logging::Logger.log "Please rename DATABASE_URL to use in the database.yml", :warn
131
+ Makara::Logging::Logger.log "Please rename DATABASE_URL to use in the database.yml", :warn
131
132
  end
132
133
  return config unless config.key?(:url)
133
134
  url = config[:url]
@@ -144,7 +145,7 @@ module Makara
144
145
  @config = config.symbolize_keys
145
146
  @makara_config = DEFAULTS.merge(@config[:makara] || {})
146
147
  @makara_config = @makara_config.symbolize_keys
147
- @id = @makara_config[:id]
148
+ @id = sanitize_id(@makara_config[:id])
148
149
  end
149
150
 
150
151
 
@@ -197,5 +198,15 @@ module Makara
197
198
 
198
199
  end
199
200
 
201
+
202
+ def sanitize_id(id)
203
+ return if id.nil? || id.empty?
204
+
205
+ id.gsub(/[\|:]/, '').tap do |sanitized_id|
206
+ if sanitized_id.size != id.size
207
+ Makara::Logging::Logger.log "Proxy id '#{id}' changed to '#{sanitized_id}'", :warn
208
+ end
209
+ end
210
+ end
200
211
  end
201
212
  end
@@ -35,11 +35,19 @@ module Makara
35
35
  @config[:name]
36
36
  end
37
37
 
38
+ def _makara_shard_id
39
+ @config[:shard_id]
40
+ end
41
+
38
42
  # has this node been blacklisted?
39
43
  def _makara_blacklisted?
40
44
  @blacklisted_until.present? && @blacklisted_until.to_i > Time.now.to_i
41
45
  end
42
46
 
47
+ def _makara_in_transaction?
48
+ @connection && @connection.open_transactions > 0 ? true : false
49
+ end
50
+
43
51
  # blacklist this node for @config[:blacklist_duration] seconds
44
52
  def _makara_blacklist!
45
53
  @connection.disconnect! if @connection
@@ -146,10 +154,10 @@ module Makara
146
154
  # The new definition should allow for the proxy to intercept the invocation if required.
147
155
  @proxy.class.hijack_methods.each do |meth|
148
156
  extension << %Q{
149
- def #{meth}(*args)
157
+ def #{meth}(*args, &block)
150
158
  _makara_hijack do |proxy|
151
159
  if proxy
152
- proxy.#{meth}(*args)
160
+ proxy.#{meth}(*args, &block)
153
161
  else
154
162
  super
155
163
  end
@@ -158,6 +166,22 @@ module Makara
158
166
  }
159
167
  end
160
168
 
169
+ # Control methods must always be passed to the
170
+ # Makara::Proxy control object for handling (typically
171
+ # related to ActiveRecord connection pool management)
172
+ @proxy.class.control_methods.each do |meth|
173
+ extension << %Q{
174
+ def #{meth}(*args, &block)
175
+ proxy = _makara
176
+ if proxy
177
+ proxy.control.#{meth}(*args=args, block)
178
+ else
179
+ super # Only if we are not wrapped any longer
180
+ end
181
+ end
182
+ }
183
+ end
184
+
161
185
  # extend the instance
162
186
  con.instance_eval(extension)
163
187
  # set the makara context
@@ -1,63 +1,134 @@
1
1
  require 'digest/md5'
2
2
 
3
- # Keeps track of the current and previous context (hexdigests)
4
- # If a new context is needed it can be generated via Makara::Context.generate
5
-
3
+ # Keeps track of the current stickiness state for different Makara proxies
6
4
  module Makara
7
5
  class Context
8
- class << self
6
+ attr_accessor :stored_data, :staged_data
7
+ attr_reader :current_timestamp
8
+
9
+ def initialize(context_data)
10
+ @stored_data = context_data
11
+ @staged_data = {}
12
+ @dirty = @was_dirty = false
13
+
14
+ freeze_time
15
+ end
9
16
 
10
- def generate(seed = nil)
11
- seed ||= "#{Time.now.to_i}#{Thread.current.object_id}#{rand(99999)}"
12
- Digest::MD5.hexdigest(seed)
17
+ def stage(proxy_id, ttl)
18
+ staged_data[proxy_id] = [staged_data[proxy_id].to_f, ttl.to_f].max
19
+ end
20
+
21
+ def stuck?(proxy_id)
22
+ stored_data[proxy_id] && !expired?(stored_data[proxy_id])
23
+ end
24
+
25
+ def staged?(proxy_id)
26
+ staged_data.key?(proxy_id)
27
+ end
28
+
29
+ def release(proxy_id)
30
+ @dirty ||= !!stored_data.delete(proxy_id)
31
+ staged_data.delete(proxy_id)
32
+ end
33
+
34
+ def release_all
35
+ if self.stored_data.any?
36
+ self.stored_data = {}
37
+ # We need to track a change made to the current stored data
38
+ # so we can commit it later
39
+ @dirty = true
13
40
  end
41
+ self.staged_data = {}
42
+ end
43
+
44
+ # Stores the staged data with an expiration time based on the current time,
45
+ # and clears any expired entries. Returns true if any changes were made to
46
+ # the current store
47
+ def commit
48
+ freeze_time
49
+ release_expired
50
+ store_staged_data
51
+ clean
14
52
 
15
- def get_previous
16
- fetch(:makara_context_previous) { generate }
53
+ was_dirty?
54
+ end
55
+
56
+ private
57
+
58
+ def freeze_time
59
+ @current_timestamp = Time.now.to_f
60
+ end
61
+
62
+ # Indicates whether there have been changes to the context that need
63
+ # to be persisted when the request finishes
64
+ def dirty?
65
+ @dirty
66
+ end
67
+
68
+ def was_dirty?
69
+ @was_dirty
70
+ end
71
+
72
+ def expired?(timestamp)
73
+ timestamp <= current_timestamp
74
+ end
75
+
76
+ def release_expired
77
+ previous_size = stored_data.size
78
+ stored_data.delete_if { |_, timestamp| expired?(timestamp) }
79
+ @dirty ||= previous_size != stored_data.size
80
+ end
81
+
82
+ def store_staged_data
83
+ staged_data.each do |proxy_id, ttl|
84
+ if ttl > 0 && self.stored_data[proxy_id].to_f < current_timestamp + ttl
85
+ self.stored_data[proxy_id] = current_timestamp + ttl
86
+ @dirty = true
87
+ end
17
88
  end
89
+ end
18
90
 
19
- def set_previous(context)
20
- previously_sticky.clear
21
- set(:makara_context_previous,context)
91
+ def clean
92
+ @was_dirty = dirty?
93
+ @dirty = false
94
+ @staged_data = {}
95
+ end
96
+
97
+ class << self
98
+ def set_current(context_data)
99
+ set(:makara_current_context, new(context_data))
22
100
  end
23
101
 
24
- def get_current
25
- fetch(:makara_context_current) { generate }
102
+ # Called by `Proxy#stick_to_master!` to use master in subsequent requests
103
+ def stick(proxy_id, ttl)
104
+ current.stage(proxy_id, ttl)
26
105
  end
27
106
 
28
- def set_current(context)
29
- set(:makara_context_current,context)
107
+ def stuck?(proxy_id)
108
+ current.staged?(proxy_id) || current.stuck?(proxy_id)
30
109
  end
31
110
 
32
- def previously_stuck?(config_id)
33
- previously_sticky.fetch(config_id) do
34
- stuck?(Makara::Context.get_previous, config_id)
111
+ def next
112
+ if current.commit
113
+ current.stored_data
35
114
  end
36
115
  end
37
116
 
38
- # Called by `Proxy#stick_to_master!` to stick subsequent requests to
39
- # master. They'll see the current context as their previous context
40
- # when they're asking whether they should be stuck to master.
41
- def stick(context, config_id, ttl)
42
- Makara::Cache.write(cache_key_for(context, config_id), '1', ttl)
117
+ def release(proxy_id)
118
+ current.release(proxy_id)
43
119
  end
44
120
 
45
- def stuck?(context, config_id)
46
- !!Makara::Cache.read(cache_key_for(context, config_id))
121
+ def release_all
122
+ current.release_all
47
123
  end
48
124
 
49
125
  protected
50
-
51
- def previously_sticky
52
- fetch(:makara_previously_sticky) { Hash.new }
53
- end
54
-
55
- def cache_key_for(context, config_id)
56
- "makara::#{context}-#{config_id}"
126
+ def current
127
+ fetch(:makara_current_context) { new({}) }
57
128
  end
58
129
 
59
130
  def fetch(key)
60
- get(key) || set(key,yield)
131
+ get(key) || set(key, yield)
61
132
  end
62
133
 
63
134
  if Thread.current.respond_to?(:thread_variable_get)
@@ -65,19 +136,18 @@ module Makara
65
136
  Thread.current.thread_variable_get(key)
66
137
  end
67
138
 
68
- def set(key,value)
69
- Thread.current.thread_variable_set(key,value)
139
+ def set(key, value)
140
+ Thread.current.thread_variable_set(key, value)
70
141
  end
71
142
  else
72
143
  def get(key)
73
144
  Thread.current[key]
74
145
  end
75
146
 
76
- def set(key,value)
147
+ def set(key, value)
77
148
  Thread.current[key]=value
78
149
  end
79
150
  end
80
-
81
151
  end
82
152
  end
83
153
  end
@@ -0,0 +1,52 @@
1
+ module Makara
2
+ module Cookie
3
+ extend self
4
+
5
+ IDENTIFIER = '_mkra_stck'.freeze
6
+
7
+ DEFAULT_OPTIONS = {
8
+ path: "/",
9
+ http_only: true
10
+ }
11
+
12
+ MAX_AGE_BUFFER = 5
13
+
14
+ def fetch(request)
15
+ parse(request.cookies[IDENTIFIER].to_s)
16
+ end
17
+
18
+ def store(context_data, headers, options = {})
19
+ unless context_data.nil?
20
+ Rack::Utils.set_cookie_header! headers, IDENTIFIER, build_cookie(context_data, options)
21
+ end
22
+ end
23
+
24
+ private
25
+ # Pairs of {proxy_id}:{timestamp}, separated by "|"
26
+ # proxy_id1:1518270031.3132212|proxy_id2:1518270030.313232 ..
27
+ def parse(cookie_string)
28
+ return {} if cookie_string.empty?
29
+
30
+ states = cookie_string.split("|")
31
+ states.each_with_object({}) do |state, context_data|
32
+ proxy_id, timestamp = state.split(":")
33
+ context_data[proxy_id] = timestamp.to_f if proxy_id && timestamp
34
+ end
35
+ end
36
+
37
+ def build_cookie(context_data, options)
38
+ cookie = DEFAULT_OPTIONS.merge(options)
39
+ now = Time.now
40
+
41
+ cookie[:max_age] = if context_data.any?
42
+ (context_data.values.max - now.to_f).ceil + MAX_AGE_BUFFER
43
+ else
44
+ 0
45
+ end
46
+ cookie[:expires] = now + cookie[:max_age]
47
+ cookie[:value] = context_data.collect { |proxy_id, ttl| "#{proxy_id}:#{ttl}" }.join('|')
48
+
49
+ cookie
50
+ end
51
+ end
52
+ end
@@ -25,13 +25,13 @@ module Makara
25
25
 
26
26
  def gracefully(connection, e)
27
27
  err = Makara::Errors::BlacklistConnection.new(connection, e)
28
- ::Makara::Logging::Logger.log("[Makara] Gracefully handling: #{err}")
28
+ ::Makara::Logging::Logger.log("Gracefully handling: #{err}")
29
29
  raise err
30
30
  end
31
31
 
32
32
 
33
33
  def harshly(e)
34
- ::Makara::Logging::Logger.log("[Makara] Harshly handling: #{e}\n#{e.backtrace.join("\n\t")}")
34
+ ::Makara::Logging::Logger.log("Harshly handling: #{e}\n#{e.backtrace.join("\n\t")}")
35
35
  raise e
36
36
  end
37
37
 
@@ -0,0 +1,14 @@
1
+ module Makara
2
+ module Errors
3
+ class BlacklistedWhileInTransaction < MakaraError
4
+
5
+ attr_reader :role
6
+
7
+ def initialize(role)
8
+ @role = role
9
+ super "[Makara] Blacklisted while in transaction in the #{role} pool"
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ module Makara
2
+ module Errors
3
+ class InvalidShard < MakaraError
4
+
5
+ attr_reader :role
6
+ attr_reader :shard_id
7
+
8
+ def initialize(role, shard_id)
9
+ @role = role
10
+ @shard_id = shard_id
11
+ super "[Makara] Invalid shard_id #{shard_id} for the #{role} pool"
12
+ end
13
+
14
+ end
15
+ end
16
+ end
@@ -5,7 +5,7 @@ module Makara
5
5
  class << self
6
6
 
7
7
  def log(msg, format = :error)
8
- logger.send(format, msg) if logger
8
+ logger.send(format, "[Makara] #{msg}") if logger
9
9
  end
10
10
 
11
11
  def logger
@@ -1,37 +1,20 @@
1
1
  require 'rack'
2
2
 
3
- # Persists the Makara::Context across requests ensuring the same master pool is used on the subsequent request.
4
- # Simply sets the cookie with the current context and the status code of this request. The next request then sets
5
- # the Makara::Context's previous context based on the the previous request. If a redirect is encountered the middleware
6
- # will defer generation of a new context until a non-redirect request occurs.
7
-
3
+ # Persists the Makara::Context across requests ensuring the same master pool is used on subsequent requests.
8
4
  module Makara
9
5
  class Middleware
10
6
 
11
- IDENTIFIER = '_mkra_ctxt'
12
-
13
- DEFAULT_COOKIE = {
14
- :path => '/',
15
- :http_only => true,
16
- :max_age => '5'
17
- }
18
-
19
7
  def initialize(app, cookie_options = {})
20
8
  @app = app
21
- @cookie = DEFAULT_COOKIE.merge(cookie_options)
9
+ @cookie_options = cookie_options
22
10
  end
23
11
 
24
-
25
12
  def call(env)
26
-
27
13
  return @app.call(env) if ignore_request?(env)
28
-
29
- Makara::Context.set_previous previous_context(env)
30
- Makara::Context.set_current new_context(env)
14
+ set_current_context(env)
31
15
 
32
16
  status, headers, body = @app.call(env)
33
-
34
- store_context(status, headers)
17
+ store_new_context(headers)
35
18
 
36
19
  [status, headers, body]
37
20
  end
@@ -39,6 +22,14 @@ module Makara
39
22
 
40
23
  protected
41
24
 
25
+ def set_current_context(env)
26
+ context_data = Makara::Cookie.fetch(Rack::Request.new(env))
27
+ Makara::Context.set_current(context_data)
28
+ end
29
+
30
+ def store_new_context(headers)
31
+ Makara::Cookie.store(Makara::Context.next, headers, @cookie_options)
32
+ end
42
33
 
43
34
  # ignore asset paths
44
35
  # consider allowing a filter proc to be provided in an initializer
@@ -50,59 +41,5 @@ module Makara
50
41
  end
51
42
  false
52
43
  end
53
-
54
-
55
- # generate a new context based on the request
56
- # if the previous request was a redirect, we keep the same context
57
- def new_context(env)
58
-
59
- makara_context, makara_status = makara_values(env)
60
-
61
- context = nil
62
-
63
- # if the previous request was a redirect, let's keep that context
64
- if makara_status.to_s =~ /^3/ # 300+ redirect
65
- context = makara_context
66
- end
67
-
68
- context ||= Makara::Context.get_current if env['rack.test']
69
- context ||= Makara::Context.generate(env["action_dispatch.request_id"])
70
- context
71
- end
72
-
73
-
74
- # pulls the previous context out of the request
75
- def previous_context(env)
76
- context = makara_values(env).first
77
- context ||= Makara::Context.get_previous if env['rack.test']
78
- context ||= Makara::Context.generate
79
- context
80
- end
81
-
82
-
83
- # retrieve the stored content from the cookie or query
84
- # The value contains the hexdigest and status code of the previous
85
- # response in the format: $digest--$status
86
- def makara_values(env)
87
- regex = /#{IDENTIFIER}=([\-a-z0-9A-Z]+)/
88
-
89
- env['HTTP_COOKIE'].to_s =~ regex
90
- return $1.split('--') if $1
91
-
92
- env['QUERY_STRING'].to_s =~ regex
93
- return $1.split('--') if $1
94
-
95
- [nil, nil]
96
- end
97
-
98
-
99
- # push the current context into the cookie
100
- # it should always be for the same path, only
101
- # accessible via http and live for a short amount
102
- # of time
103
- def store_context(status, headers)
104
- cookie = @cookie.merge(:value => "#{Makara::Context.get_current}--#{status}")
105
- Rack::Utils.set_cookie_header! headers, IDENTIFIER, cookie
106
- end
107
44
  end
108
45
  end