routemaster-drain 2.3.0 → 2.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +19 -0
  3. data/.env.test +2 -2
  4. data/.rubocop.yml +1156 -0
  5. data/.ruby-version +1 -1
  6. data/.travis.yml +8 -0
  7. data/Appraisals +3 -3
  8. data/CHANGELOG.md +31 -5
  9. data/Gemfile +7 -6
  10. data/Gemfile.lock +23 -17
  11. data/README.md +19 -0
  12. data/appraise +28 -0
  13. data/gemfiles/rails_3.gemfile +8 -8
  14. data/gemfiles/rails_3.gemfile.lock +64 -58
  15. data/gemfiles/rails_4.gemfile +8 -8
  16. data/gemfiles/rails_4.gemfile.lock +121 -92
  17. data/gemfiles/rails_5.gemfile +8 -8
  18. data/gemfiles/rails_5.gemfile.lock +78 -72
  19. data/lib/core_ext/forwardable.rb +14 -0
  20. data/lib/routemaster/api_client.rb +65 -36
  21. data/lib/routemaster/cache.rb +7 -1
  22. data/lib/routemaster/cache_key.rb +7 -0
  23. data/lib/routemaster/config.rb +12 -13
  24. data/lib/routemaster/dirty/map.rb +1 -1
  25. data/lib/routemaster/drain.rb +1 -1
  26. data/lib/routemaster/event_index.rb +21 -0
  27. data/lib/routemaster/jobs.rb +2 -0
  28. data/lib/routemaster/jobs/cache_and_sweep.rb +2 -1
  29. data/lib/routemaster/jobs/job.rb +2 -0
  30. data/lib/routemaster/middleware/cache.rb +2 -5
  31. data/lib/routemaster/middleware/parse.rb +2 -2
  32. data/lib/routemaster/middleware/response_caching.rb +54 -24
  33. data/lib/routemaster/null_logger.rb +16 -0
  34. data/lib/routemaster/redis_broker.rb +8 -7
  35. data/lib/routemaster/resources/rest_resource.rb +18 -7
  36. data/lib/routemaster/responses/future_response.rb +37 -17
  37. data/lib/routemaster/responses/hateoas_enumerable_response.rb +47 -0
  38. data/lib/routemaster/responses/hateoas_response.rb +9 -12
  39. data/routemaster-drain.gemspec +2 -2
  40. data/spec/routemaster/api_client_spec.rb +118 -44
  41. data/spec/routemaster/drain/caching_spec.rb +4 -3
  42. data/spec/routemaster/integration/api_client_spec.rb +266 -102
  43. data/spec/routemaster/integration/cache_spec.rb +52 -39
  44. data/spec/routemaster/middleware/cache_spec.rb +4 -6
  45. data/spec/routemaster/redis_broker_spec.rb +11 -11
  46. data/spec/routemaster/resources/rest_resource_spec.rb +4 -2
  47. data/spec/routemaster/responses/future_response_spec.rb +18 -0
  48. data/spec/routemaster/responses/hateoas_enumerable_response_spec.rb +78 -0
  49. data/spec/routemaster/responses/hateoas_response_spec.rb +52 -53
  50. data/spec/spec_helper.rb +2 -1
  51. data/spec/support/breakpoint_class.rb +14 -0
  52. data/spec/support/server.rb +52 -0
  53. data/spec/support/uses_redis.rb +2 -2
  54. metadata +26 -10
  55. data/test.rb +0 -17
@@ -0,0 +1,14 @@
1
+ if RUBY_VERSION == '2.4.0'
2
+ # MRI 2.4.0 has a bug in ext/rubyvm/lib/forwardable/impl.rb
3
+ # This severaly affects gems like Faraday which extensively use delegation to
4
+ # provide syntax sugar.
5
+ #
6
+ # This patch replaces it with the portable version in lib/forwardable/impl.rb
7
+ # Source: https://bugs.ruby-lang.org/issues/13107
8
+ require 'forwardable'
9
+ module Forwardable
10
+ def self._compile_method(src, file, line)
11
+ eval(src, nil, file, line)
12
+ end
13
+ end
14
+ end
@@ -1,13 +1,28 @@
1
+ require 'core_ext/forwardable'
1
2
  require 'base64'
2
3
  require 'faraday'
3
4
  require 'faraday_middleware'
4
- require 'hashie'
5
+ require 'typhoeus'
5
6
  require 'routemaster/config'
6
7
  require 'routemaster/middleware/response_caching'
7
8
  require 'routemaster/middleware/error_handling'
8
9
  require 'routemaster/middleware/metrics'
9
10
  require 'routemaster/responses/future_response'
10
11
 
12
+ # Loading the Faraday adapter for Typhoeus requires a little dance
13
+ require 'faraday/adapter/typhoeus'
14
+ require 'typhoeus/adapters/faraday'
15
+
16
+ # The following requires are not direct dependencies, but loading them early
17
+ # prevents Faraday's magic class loading pixie dust from tripping over itself in
18
+ # multithreaded use cases.
19
+ require 'uri'
20
+ require 'faraday/request/retry'
21
+ require 'faraday_middleware/request/encode_json'
22
+ require 'faraday_middleware/response/parse_json'
23
+ require 'faraday_middleware/response/mashify'
24
+ require 'hashie/mash'
25
+
11
26
  module Routemaster
12
27
  class APIClient
13
28
  def initialize(middlewares: [],
@@ -20,6 +35,8 @@ module Routemaster
20
35
  @response_class = response_class
21
36
  @metrics_client = metrics_client
22
37
  @source_peer = source_peer
38
+
39
+ connection # warm up connection so Faraday does all it's magical file loading in the main thread
23
40
  end
24
41
 
25
42
  # Performs a GET HTTP request for the `url`, with optional
@@ -28,69 +45,77 @@ module Routemaster
28
45
  # @return an object that responds to `status` (integer), `headers` (hash),
29
46
  # and `body`. The body is a `Hashie::Mash` if the response was JSON, a
30
47
  # string otherwise.
31
- def get(url, params: {}, headers: {})
32
- host = URI.parse(url).host
33
- response_wrapper do
34
- connection.get(url, params, headers.merge(auth_header(host)))
35
- end
48
+ def get(url, params: {}, headers: {}, options: {})
49
+ enable_caching = options.fetch(:enable_caching, true)
50
+
51
+ _wrapped_response _request(
52
+ :get,
53
+ url: url,
54
+ params: params,
55
+ headers: headers.merge(response_cache_opt_headers(enable_caching)))
36
56
  end
37
57
 
38
- def fget(url, params: {}, headers: {})
39
- Responses::FutureResponse.new { get(url, params: {}, headers: {}) }
58
+ # Same as {{get}}, except with
59
+ def fget(url, **options)
60
+ uri = _assert_uri(url)
61
+ Responses::FutureResponse.new { get(uri, options) }
40
62
  end
41
63
 
42
64
  def post(url, body: {}, headers: {})
43
- host = URI.parse(url).host
44
- response_wrapper do
45
- connection.post do |req|
46
- req.url url
47
- req.headers = headers.merge(auth_header(host))
48
- req.body = body
49
- end
50
- end
65
+ _request(:post, url: url, body: body, headers: headers)
51
66
  end
52
67
 
53
68
  def patch(url, body: {}, headers: {})
54
- host = URI.parse(url).host
55
- response_wrapper do
56
- connection.patch do |req|
57
- req.url url
58
- req.headers = headers.merge(auth_header(host))
59
- req.body = body
60
- end
61
- end
69
+ _request(:patch, url: url, body: body, headers: headers)
62
70
  end
63
71
 
64
72
  def delete(url, headers: {})
65
- host = URI.parse(url).host
66
- response_wrapper do
67
- connection.delete do |req|
68
- req.url url
69
- req.headers = headers.merge(auth_header(host))
70
- end
71
- end
73
+ _request(:delete, url: url, body: nil, headers: headers)
72
74
  end
73
75
 
74
76
  def discover(url)
75
77
  get(url)
76
78
  end
77
79
 
80
+ def with_response(response_class)
81
+ memo = @response_class
82
+ @response_class = response_class
83
+ yield self
84
+ ensure
85
+ @response_class = memo
86
+ end
87
+
78
88
  private
79
89
 
80
- def response_wrapper(&block)
81
- response = block.call
90
+ def _assert_uri(url)
91
+ return url if url.kind_of?(URI)
92
+ URI.parse(url)
93
+ end
94
+
95
+ def _request(method, url:, body: nil, headers:, params: {})
96
+ uri = _assert_uri(url)
97
+ auth = auth_header(uri.host)
98
+ connection.public_send(method) do |req|
99
+ req.url uri.to_s
100
+ req.params.merge! params
101
+ req.headers = headers.merge(auth)
102
+ req.body = body
103
+ end
104
+ end
105
+
106
+ def _wrapped_response(response)
82
107
  @response_class ? @response_class.new(response, client: self) : response
83
108
  end
84
109
 
85
110
  def connection
86
111
  @connection ||= Faraday.new do |f|
87
- f.request :json
88
- f.request :retry, max: 2, interval: 100e-3, backoff_factor: 2
112
+ f.request :json
113
+ f.request :retry, max: 2, interval: 100e-3, backoff_factor: 2
89
114
  f.response :mashify
90
115
  f.response :json, content_type: /\bjson/
91
116
  f.use Routemaster::Middleware::ResponseCaching, listener: @listener
92
117
  f.use Routemaster::Middleware::Metrics, client: @metrics_client, source_peer: @source_peer
93
- f.adapter :net_http_persistent
118
+ f.adapter :typhoeus
94
119
  f.use Routemaster::Middleware::ErrorHandling
95
120
 
96
121
  @middlewares.each do |middleware|
@@ -107,5 +132,9 @@ module Routemaster
107
132
  auth_string = Config.cache_auth.fetch(host, []).join(':')
108
133
  { 'Authorization' => "Basic #{Base64.strict_encode64(auth_string)}" }
109
134
  end
135
+
136
+ def response_cache_opt_headers(value)
137
+ { Routemaster::Middleware::ResponseCaching::RESPONSE_CACHING_OPT_HEADER => value.to_s }
138
+ end
110
139
  end
111
140
  end
@@ -1,4 +1,6 @@
1
1
  require 'routemaster/api_client'
2
+ require 'routemaster/cache_key'
3
+ require 'routemaster/event_index'
2
4
  require 'wisper'
3
5
 
4
6
  module Routemaster
@@ -23,10 +25,14 @@ module Routemaster
23
25
 
24
26
  # Bust the cache for a given URL
25
27
  def bust(url)
26
- @redis.del("cache:#{url}")
28
+ @redis.del(Routemaster::CacheKey.url_key(url))
27
29
  _publish(:cache_bust, url)
28
30
  end
29
31
 
32
+ def invalidate(url)
33
+ EventIndex.new(url, cache: @redis).increment
34
+ end
35
+
30
36
  # This is because wisper makes broadcasting methods private
31
37
  def _publish(event, url)
32
38
  publish(event, url)
@@ -0,0 +1,7 @@
1
+ module Routemaster
2
+ class CacheKey
3
+ def self.url_key(url)
4
+ "cache:#{url}"
5
+ end
6
+ end
7
+ end
@@ -2,26 +2,34 @@ require 'singleton'
2
2
  require 'hashie/rash'
3
3
  require 'set'
4
4
  require 'routemaster/redis_broker'
5
+ require 'routemaster/null_logger'
5
6
 
6
7
  module Routemaster
7
8
  class Config
9
+ include Singleton
8
10
  module Classmethods
9
11
  def method_missing(method, *args, &block)
10
- new.send(method, *args, &block)
12
+ instance.send(method, *args, &block)
11
13
  end
12
14
 
13
15
  def respond_to?(method, include_all = false)
14
- new.respond_to?(method, include_all)
16
+ instance.respond_to?(method, include_all)
15
17
  end
16
18
  end
17
19
  extend Classmethods
18
20
 
21
+ attr_writer :logger
22
+
23
+ def logger
24
+ @logger ||= NullLogger.new
25
+ end
26
+
19
27
  def drain_redis
20
- RedisBroker.instance.get(ENV.fetch('ROUTEMASTER_DRAIN_REDIS'))
28
+ RedisBroker.instance.get(:drain_redis, urls: ENV.fetch('ROUTEMASTER_DRAIN_REDIS').split(','))
21
29
  end
22
30
 
23
31
  def cache_redis
24
- RedisBroker.instance.get(ENV.fetch('ROUTEMASTER_CACHE_REDIS'))
32
+ RedisBroker.instance.get(:cache_redis, urls: ENV.fetch('ROUTEMASTER_CACHE_REDIS').split(','))
25
33
  end
26
34
 
27
35
  #
@@ -62,14 +70,5 @@ module Routemaster
62
70
  def drain_tokens
63
71
  Set.new(ENV.fetch('ROUTEMASTER_DRAIN_TOKENS').split(','))
64
72
  end
65
-
66
- def url_expansions
67
- Hashie::Rash.new.tap do |result|
68
- ENV.fetch('ROUTEMASTER_URL_EXPANSIONS', '').split(',').each do |entry|
69
- host, username, password = entry.split(':')
70
- result[Regexp.new(host)] = [username, password]
71
- end
72
- end
73
- end
74
73
  end
75
74
  end
@@ -44,7 +44,7 @@ module Routemaster
44
44
  # It is possible to call +next+ or +break+ from the block.
45
45
  def sweep(limit = 0)
46
46
  unswept = []
47
- while url = @redis.spop(KEY)
47
+ while (url = @redis.spop(KEY))
48
48
  unswept.push url
49
49
  is_swept = !! yield(url)
50
50
  unswept.pop if is_swept
@@ -1,5 +1,5 @@
1
1
  module Routemaster
2
2
  module Drain
3
- VERSION = '2.3.0'
3
+ VERSION = '2.4.0'
4
4
  end
5
5
  end
@@ -0,0 +1,21 @@
1
+ require 'routemaster/cache_key'
2
+ module Routemaster
3
+ class EventIndex
4
+ attr_reader :url, :cache
5
+
6
+ def initialize(url, cache: Config.cache_redis)
7
+ @url = url
8
+ @cache = cache
9
+ end
10
+
11
+ def increment
12
+ i = cache.hincrby(CacheKey.url_key(url), 'current_index', 1).to_i
13
+ Config.logger.debug("DRAIN: Increment #{@url} to #{i}") if Config.logger.debug?
14
+ i
15
+ end
16
+
17
+ def current
18
+ (cache.hget(CacheKey.url_key(url), 'current_index') || 0).to_i
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,2 @@
1
+ require_relative 'jobs/cache'
2
+ require_relative 'jobs/cache_and_sweep'
@@ -9,8 +9,9 @@ module Routemaster
9
9
  def perform(url)
10
10
  Dirty::Map.new.sweep_one(url) do
11
11
  begin
12
- Cache.new.get(url)
12
+ Routemaster::Cache.new.get(url)
13
13
  rescue Errors::ResourceNotFound
14
+ nil # nothing to cache
14
15
  end
15
16
  end
16
17
  end
@@ -1,3 +1,5 @@
1
+ require 'routemaster/jobs'
2
+
1
3
  module Routemaster
2
4
  module Jobs
3
5
  class Job
@@ -2,6 +2,7 @@ require 'routemaster/cache'
2
2
  require 'routemaster/config'
3
3
  require 'routemaster/jobs/client'
4
4
  require 'routemaster/jobs/cache_and_sweep'
5
+ require 'routemaster/event_index'
5
6
 
6
7
  module Routemaster
7
8
  module Middleware
@@ -15,7 +16,7 @@ module Routemaster
15
16
 
16
17
  def call(env)
17
18
  env.fetch('routemaster.dirty', []).each do |url|
18
- @cache.bust(url)
19
+ @cache.invalidate(url)
19
20
  @client.enqueue(@queue, Routemaster::Jobs::CacheAndSweep, url)
20
21
  end
21
22
  @app.call(env)
@@ -23,7 +24,3 @@ module Routemaster
23
24
  end
24
25
  end
25
26
  end
26
-
27
-
28
-
29
-
@@ -15,10 +15,10 @@ module Routemaster
15
15
  end
16
16
 
17
17
  def call(env)
18
- if env['CONTENT_TYPE'] != 'application/json'
18
+ if (env['CONTENT_TYPE'] != 'application/json')
19
19
  return [415, {}, []]
20
20
  end
21
- if payload = _extract_payload(env)
21
+ if (payload = _extract_payload(env))
22
22
  env['routemaster.payload'] = payload
23
23
  else
24
24
  return [400, {}, []]
@@ -1,55 +1,69 @@
1
1
  require 'wisper'
2
+ require 'routemaster/event_index'
3
+ require 'routemaster/cache_key'
2
4
 
3
5
  module Routemaster
4
6
  module Middleware
5
7
  class ResponseCaching
6
- KEY_TEMPLATE = 'cache:{url}'
7
- BODY_FIELD_TEMPLATE = 'v:{version},l:{locale},body'
8
- HEADERS_FIELD_TEMPLATE = 'v:{version},l:{locale},headers'
9
- VERSION_REGEX = /application\/json;v=(?<version>\S*)/.freeze
8
+ BODY_FIELD_TEMPLATE = 'v:{version},l:{locale},body'.freeze
9
+ HEADERS_FIELD_TEMPLATE = 'v:{version},l:{locale},headers'.freeze
10
+ VERSION_REGEX = /application\/json;v=(?<version>\S*)/
11
+ RESPONSE_CACHING_OPT_HEADER = 'X-routemaster_drain.opt_cache'.freeze
10
12
 
11
13
  def initialize(app, cache: Config.cache_redis, listener: nil)
12
14
  @app = app
13
15
  @cache = cache
14
- @expiry = Config.cache_expiry
16
+ @expiry = Config.cache_expiry
15
17
  @listener = listener
16
18
  end
17
19
 
18
20
  def call(env)
19
21
  @cache.del(cache_key(env)) if %i(patch delete).include?(env.method)
20
22
  return @app.call(env) if env.method != :get
21
-
22
- fetch_from_cache(env) || fetch_from_service(env)
23
+ fetch_from_cache(env) || fetch_from_service(env, event_index(env))
23
24
  end
24
25
 
25
26
  private
26
27
 
27
- def fetch_from_service(env)
28
+ def fetch_from_service(env, event_index)
28
29
  @app.call(env).on_complete do |response_env|
29
30
  response = response_env.response
30
31
 
31
- if response.success?
32
- @cache.multi do |multi|
33
- multi.hset(cache_key(env), body_cache_field(env), response.body)
34
- multi.hset(cache_key(env), headers_cache_field(env), Marshal.dump(response.headers))
35
- multi.expire(cache_key(env), @expiry)
32
+ if response.success? && cache_enabled?(env)
33
+ namespaced_key = "#{@cache.namespace}:#{cache_key(env)}"
34
+ @cache.redis.node_for(namespaced_key).multi do |node|
35
+ if Config.logger.debug?
36
+ Config.logger.debug("DRAIN: Saving #{url(env)} with a event index of #{event_index}")
37
+ end
38
+
39
+ node.hmset(namespaced_key,
40
+ body_cache_field(env), response.body,
41
+ headers_cache_field(env), Marshal.dump(response.headers),
42
+ :most_recent_index, event_index)
43
+ node.expire(namespaced_key, @expiry)
36
44
  end
45
+
37
46
  @listener._publish(:cache_miss, url(env)) if @listener
38
47
  end
39
48
  end
40
49
  end
41
50
 
42
51
  def fetch_from_cache(env)
43
- body = @cache.hget(cache_key(env), body_cache_field(env))
44
- headers = @cache.hget(cache_key(env), headers_cache_field(env))
45
-
46
- if body && headers
47
- @listener._publish(:cache_hit, url(env)) if @listener
48
- Faraday::Response.new(status: 200,
49
- body: body,
50
- response_headers: Marshal.load(headers),
51
- request: {})
52
+ return nil unless cache_enabled?(env)
53
+ body, headers, most_recent_index, current_index = currently_cached_content(env)
54
+
55
+ unless most_recent_index.to_i == current_index.to_i && body && headers
56
+ Config.logger.debug("DRAIN: Cache miss #{url(env)} - index_recent: #{most_recent_index.to_i}") if Config.logger.debug?
57
+ return nil
52
58
  end
59
+
60
+ Config.logger.debug("DRAIN: Cache hit #{url(env)} - index_recent: #{most_recent_index.to_i}") if Config.logger.debug?
61
+ @listener._publish(:cache_hit, url(env)) if @listener
62
+
63
+ Faraday::Response.new(status: 200,
64
+ body: body,
65
+ response_headers: Marshal.load(headers),
66
+ request: {})
53
67
  end
54
68
 
55
69
  def body_cache_field(env)
@@ -65,7 +79,7 @@ module Routemaster
65
79
  end
66
80
 
67
81
  def cache_key(env)
68
- KEY_TEMPLATE.gsub('{url}', url(env))
82
+ CacheKey.url_key(url(env))
69
83
  end
70
84
 
71
85
  def url(env)
@@ -73,12 +87,28 @@ module Routemaster
73
87
  end
74
88
 
75
89
  def version(env)
76
- (env.request_headers['Accept'] || "")[VERSION_REGEX, 1]
90
+ (env.request_headers['Accept'] || '')[VERSION_REGEX, 1]
77
91
  end
78
92
 
79
93
  def locale(env)
80
94
  env.request_headers['Accept-Language']
81
95
  end
96
+
97
+ def cache_enabled?(env)
98
+ env.request_headers[RESPONSE_CACHING_OPT_HEADER].to_s == 'true'
99
+ end
100
+
101
+ def event_index(env)
102
+ Routemaster::EventIndex.new(url(env)).current
103
+ end
104
+
105
+ def currently_cached_content(env)
106
+ @cache.hmget(cache_key(env),
107
+ body_cache_field(env),
108
+ headers_cache_field(env),
109
+ :most_recent_index,
110
+ :current_index)
111
+ end
82
112
  end
83
113
  end
84
114
  end