routemaster-drain 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.env.test +6 -0
  3. data/.gitignore +7 -0
  4. data/.gitmodules +3 -0
  5. data/.rspec +3 -0
  6. data/.ruby-version +1 -0
  7. data/.travis.yml +16 -0
  8. data/.yardopts +6 -0
  9. data/Gemfile +17 -0
  10. data/Gemfile.lock +122 -0
  11. data/Guardfile +10 -0
  12. data/LICENSE.txt +22 -0
  13. data/README.md +261 -0
  14. data/Rakefile +1 -0
  15. data/lib/routemaster/cache.rb +133 -0
  16. data/lib/routemaster/config.rb +57 -0
  17. data/lib/routemaster/dirty/filter.rb +49 -0
  18. data/lib/routemaster/dirty/map.rb +63 -0
  19. data/lib/routemaster/dirty/state.rb +33 -0
  20. data/lib/routemaster/drain.rb +5 -0
  21. data/lib/routemaster/drain/basic.rb +40 -0
  22. data/lib/routemaster/drain/caching.rb +43 -0
  23. data/lib/routemaster/drain/mapping.rb +43 -0
  24. data/lib/routemaster/drain/terminator.rb +31 -0
  25. data/lib/routemaster/fetcher.rb +63 -0
  26. data/lib/routemaster/jobs/cache.rb +12 -0
  27. data/lib/routemaster/jobs/cache_and_sweep.rb +16 -0
  28. data/lib/routemaster/middleware/authenticate.rb +59 -0
  29. data/lib/routemaster/middleware/cache.rb +29 -0
  30. data/lib/routemaster/middleware/dirty.rb +33 -0
  31. data/lib/routemaster/middleware/filter.rb +26 -0
  32. data/lib/routemaster/middleware/parse.rb +43 -0
  33. data/lib/routemaster/middleware/root_post_only.rb +18 -0
  34. data/lib/routemaster/redis_broker.rb +42 -0
  35. data/routemaster-drain.gemspec +28 -0
  36. data/spec/routemaster/cache_spec.rb +115 -0
  37. data/spec/routemaster/dirty/filter_spec.rb +77 -0
  38. data/spec/routemaster/dirty/map_spec.rb +122 -0
  39. data/spec/routemaster/dirty/state_spec.rb +41 -0
  40. data/spec/routemaster/drain/basic_spec.rb +37 -0
  41. data/spec/routemaster/drain/caching_spec.rb +47 -0
  42. data/spec/routemaster/drain/mapping_spec.rb +51 -0
  43. data/spec/routemaster/drain/terminator_spec.rb +61 -0
  44. data/spec/routemaster/fetcher_spec.rb +56 -0
  45. data/spec/routemaster/middleware/authenticate_spec.rb +59 -0
  46. data/spec/routemaster/middleware/cache_spec.rb +35 -0
  47. data/spec/routemaster/middleware/dirty_spec.rb +33 -0
  48. data/spec/routemaster/middleware/filter_spec.rb +35 -0
  49. data/spec/routemaster/middleware/parse_spec.rb +69 -0
  50. data/spec/routemaster/middleware/root_post_only_spec.rb +30 -0
  51. data/spec/spec_helper.rb +16 -0
  52. data/spec/support/events.rb +9 -0
  53. data/spec/support/rack_test.rb +23 -0
  54. data/spec/support/uses_dotenv.rb +11 -0
  55. data/spec/support/uses_redis.rb +15 -0
  56. data/spec/support/uses_webmock.rb +12 -0
  57. data/test.rb +17 -0
  58. metadata +247 -0
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,133 @@
1
+ require 'routemaster/fetcher'
2
+ require 'thread/pool'
3
+ require 'thread/future'
4
+ require 'singleton'
5
+ require 'delegate'
6
+ require 'json'
7
+ require 'wisper'
8
+
9
+ module Routemaster
10
+ # Caches GET requests.
11
+ #
12
+ # Emits `cache_bust`, `cache_hit`, and `cache_miss` events.
13
+ #
14
+ # The requests themselves are handled by {Fetcher}.
15
+ # Note that `Cache-Control` headers are intentionally ignored, as it is
16
+ # assumed one will call {#bust} when the cache becomes stale.
17
+ #
18
+ # This is for instance done automatically by {Middleware::Cache}
19
+ # upon receiving events from Routemaster.
20
+ #
21
+ class Cache
22
+ include Wisper::Publisher
23
+
24
+ # A pool of threads, used for parallel/future request processing.
25
+ class Pool < SimpleDelegator
26
+ include Singleton
27
+
28
+ def initialize
29
+ Thread.pool(5, 20).tap do |p|
30
+ # TODO: configurable pool size and trim timeout?
31
+ p.auto_trim!
32
+ p.idle_trim! 10 # 10 seconds
33
+ super p
34
+ end
35
+ end
36
+ end
37
+
38
+ # Wraps a future response, so it quacks exactly like an ordinary response.
39
+ class FutureResponse
40
+ extend Forwardable
41
+
42
+ # The `block` is expected to return a {Response}
43
+ def initialize(&block)
44
+ @future = Pool.instance.future(&block)
45
+ end
46
+
47
+ # @!attribute status
48
+ # @return [Integer]
49
+ # Delegated to the `block`'s return value.
50
+
51
+ # @!attribute headers
52
+ # @return [Hash]
53
+ # Delegated to the `block`'s return value.
54
+
55
+ # @!attribute body
56
+ # @return pHashie::Mash]
57
+ # Delegated to the `block`'s return value.
58
+
59
+ delegate :value => :@future
60
+ delegate %i(status headers body) => :value
61
+ end
62
+
63
+ class Response < Hashie::Mash
64
+ # @!attribute status
65
+ # Integer
66
+
67
+ # @!attribute headers
68
+ # Hash
69
+
70
+ # @!attribute body
71
+ # Hashie::Mash
72
+ end
73
+
74
+
75
+ def initialize(redis:nil, fetcher:nil)
76
+ @redis = redis || Config.cache_redis
77
+ @expiry = Config.cache_expiry
78
+ @fetcher = fetcher || Fetcher
79
+ end
80
+
81
+ # Bust the cache for a given URL
82
+ def bust(url)
83
+ @redis.del("cache:#{url}")
84
+ publish(:cache_bust, url)
85
+ end
86
+
87
+ # Get the response from a URL, from the cache if possible.
88
+ # Stores to the cache on misses.
89
+ #
90
+ # Different versions and locales are stored separately in the cache.
91
+ #
92
+ # @param version [Integer] The version to pass in headers, as `Accept: application/json;v=2`
93
+ # @param locale [String] The language to request in the `Accept-Language`
94
+ # header.
95
+ #
96
+ # @return [Response], which responds to `status`, `headers`, and `body`.
97
+ def get(url, version:nil, locale:nil)
98
+ key = "cache:#{url}"
99
+ field = "v:#{version},l:#{locale}"
100
+
101
+ # check cache
102
+ if payload = @redis.hget(key, field)
103
+ publish(:cache_hit, url)
104
+ return Response.new(JSON.parse(payload))
105
+ end
106
+
107
+ # fetch data
108
+ headers = {
109
+ 'Accept' => version ?
110
+ "application/json;v=#{version}" :
111
+ "application/json"
112
+ }
113
+ headers['Accept-Language'] = locale if locale
114
+ response = @fetcher.get(url, headers: headers)
115
+
116
+ # store in redis
117
+ @redis.hset(key, field, response.to_json)
118
+ @redis.expire(key, @expiry)
119
+
120
+ publish(:cache_miss, url)
121
+ Response.new(response)
122
+ end
123
+
124
+ # Like {#get}, but schedules any request in the background using a thread
125
+ # pool. Handy to issue lots of requests in parallel.
126
+ #
127
+ # @return [FutureResponse], which responds to `status`, `headers`, and `body`
128
+ # like [Response].
129
+ def fget(*args)
130
+ FutureResponse.new { get(*args) }
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,57 @@
1
+ require 'singleton'
2
+ require 'hashie/rash'
3
+ require 'set'
4
+ require 'routemaster/redis_broker'
5
+
6
+ module Routemaster
7
+ class Config
8
+ module Classmethods
9
+ def method_missing(method, *args, &block)
10
+ new.send(method, *args, &block)
11
+ end
12
+
13
+ def respond_to?(method, include_all = false)
14
+ new.respond_to?(method, include_all)
15
+ end
16
+ end
17
+ extend Classmethods
18
+
19
+ def drain_redis
20
+ RedisBroker.instance.get(ENV.fetch('ROUTEMASTER_DRAIN_REDIS'))
21
+ end
22
+
23
+ def cache_redis
24
+ RedisBroker.instance.get(ENV.fetch('ROUTEMASTER_CACHE_REDIS'))
25
+ end
26
+
27
+ def cache_expiry
28
+ Integer(ENV.fetch('ROUTEMASTER_CACHE_EXPIRY', 86_400 * 365))
29
+ end
30
+
31
+ def cache_auth
32
+ Hashie::Rash.new.tap do |result|
33
+ ENV.fetch('ROUTEMASTER_CACHE_AUTH', '').split(',').each do |entry|
34
+ host, username, password = entry.split(':')
35
+ result[Regexp.new(host)] = [username, password]
36
+ end
37
+ end
38
+ end
39
+
40
+ def queue_name
41
+ ENV.fetch('ROUTEMASTER_QUEUE_NAME', 'routemaster')
42
+ end
43
+
44
+ def drain_tokens
45
+ Set.new(ENV.fetch('ROUTEMASTER_DRAIN_TOKENS').split(','))
46
+ end
47
+
48
+ def url_expansions
49
+ Hashie::Rash.new.tap do |result|
50
+ ENV.fetch('ROUTEMASTER_URL_EXPANSIONS', '').split(',').each do |entry|
51
+ host, username, password = entry.split(':')
52
+ result[Regexp.new(host)] = [username, password]
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,49 @@
1
+ require 'routemaster/dirty/state'
2
+ require 'routemaster/config'
3
+ module Routemaster
4
+ module Dirty
5
+ # Service object, filters an event payload, only include events that reflect
6
+ # an entity state that is _more recent_ than previously received events.
7
+ #
8
+ # Can be used to Ignore events received out-of-order (e.g. an `update` event
9
+ # about en entity received after the `delete` event for that same entity),
10
+ # given Routemaster makes no guarantee of in-order delivery of events.
11
+ #
12
+ class Filter
13
+ EXPIRY = 86_400
14
+
15
+ # @param redis [Redis, Redis::Namespace] a connection to Redis, used to
16
+ # persists the known state
17
+ def initialize(redis:nil)
18
+ @redis = redis || Config.drain_redis
19
+ @expiry = Config.cache_expiry
20
+ end
21
+
22
+ # Process a payload, and returns part if this payload containing
23
+ # only the latest event for a given entity.
24
+ #
25
+ # Events are skipped if they are older than a previously processed
26
+ # event for the same entity.
27
+ #
28
+ # Order of kept events is not guaranteed to be preserved.
29
+ def run(payload)
30
+ events = {} # url -> event
31
+
32
+ payload.each do |event|
33
+ known_state = State.get(@redis, event['url'])
34
+
35
+ # skip events older than what we already know
36
+ next if known_state.t > event['t']
37
+
38
+ new_state = State.new(event['url'], event['t'])
39
+
40
+ next if new_state == known_state
41
+ new_state.save(@redis, @expiry)
42
+ events[event['url']] = event
43
+ end
44
+
45
+ events.values
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,63 @@
1
+ require 'delegate'
2
+ require 'set'
3
+ require 'wisper'
4
+
5
+ module Routemaster
6
+ module Dirty
7
+ # Collects information about entities whose state has changed and need to be
8
+ # refreshed.
9
+ # Typically +mark+ is called when notified state has changed (e.g. from the
10
+ # bus) and +sweep+ when one wants to know what has changed.
11
+ #
12
+ # Use case: when some entites are very volatile, the map will hold a "dirty"
13
+ # state for multiple updates until the client is ready to update.
14
+ #
15
+ class Map
16
+ include Wisper::Publisher
17
+ KEY = 'dirtymap:items'
18
+
19
+ def initialize(redis: nil)
20
+ @redis = redis || Config.drain_redis
21
+ end
22
+
23
+ # Marks an entity as dirty.
24
+ # Return true if newly marked, false if re-marking.
25
+ def mark(url)
26
+ @redis.sadd(KEY, url).tap do |marked|
27
+ publish(:dirty_entity, url) if marked
28
+ end
29
+ end
30
+
31
+ # Runs the block.
32
+ # The entity will only be marked as clean if the block returns truthy.
33
+ def sweep_one(url, &block)
34
+ return unless block.call(url)
35
+ @redis.srem(KEY, url)
36
+ end
37
+
38
+ def all
39
+ @redis.smembers(KEY)
40
+ end
41
+
42
+ # Yields URLs for dirty entitities.
43
+ # The entity will only be marked as clean if the block returns truthy.
44
+ # It is possible to call +next+ or +break+ from the block.
45
+ def sweep(limit = 0)
46
+ unswept = []
47
+ while url = @redis.spop(KEY)
48
+ unswept.push url
49
+ is_swept = !! yield(url)
50
+ unswept.pop if is_swept
51
+ break if (limit -=1).zero?
52
+ end
53
+ ensure
54
+ @redis.sadd(KEY, unswept) if unswept.any?
55
+ end
56
+
57
+ # Number of currently dirty entities.
58
+ def count
59
+ @redis.scard(KEY)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,33 @@
1
+ require 'delegate'
2
+ require 'set'
3
+
4
+ module Routemaster
5
+ module Dirty
6
+ # Locale prepresentation of the state of an entity.
7
+ # - url (string): the entity's authoritative locator
8
+ # - t (datetime, UTC): when the state was last refreshed
9
+ class State < Struct.new(:url, :t)
10
+ KEY = 'dirtymap:state:%s'
11
+
12
+ # Given a `redis` instance, return
13
+ #
14
+ # - a "blank" state for that URL (with time stamp 0), if the state is
15
+ # unknown; or
16
+ # - the entity state, if known.
17
+ def self.get(redis, url)
18
+ data = redis.get(KEY % url)
19
+ return new(url, 0) if data.nil?
20
+ Marshal.load(data)
21
+ end
22
+
23
+ # Given a `redis` instance, save the state, expiring after
24
+ # `expiry` seconds.
25
+ def save(redis, expiry)
26
+ data = Marshal.dump(self)
27
+ redis.set(KEY % url, data, ex: expiry)
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+
@@ -0,0 +1,5 @@
1
+ module Routemaster
2
+ module Drain
3
+ VERSION = '1.0.0'
4
+ end
5
+ end
@@ -0,0 +1,40 @@
1
+ require 'routemaster/middleware/root_post_only'
2
+ require 'routemaster/middleware/authenticate'
3
+ require 'routemaster/middleware/parse'
4
+ require 'routemaster/drain/terminator'
5
+ require 'rack/builder'
6
+ require 'delegate'
7
+
8
+ module Routemaster
9
+ module Drain
10
+ # Rack application which authenticates, parses, and broadcasts events
11
+ # received from Routemaster.
12
+ #
13
+ # See the various corresponding middleware for details on operation:
14
+ # {Middleware::Authenticate}, {Middleware::Parse}, and terminates with
15
+ # {Terminator}.
16
+ #
17
+ class Basic
18
+ extend Forwardable
19
+
20
+ def initialize(options = {})
21
+ @terminator = terminator = Terminator.new
22
+ @app = ::Rack::Builder.app do
23
+ use Middleware::RootPostOnly
24
+ use Middleware::Authenticate, options
25
+ use Middleware::Parse
26
+ run terminator
27
+ end
28
+ end
29
+
30
+ # delegate :call => :@app
31
+
32
+ def call(env)
33
+ @app.call(env)
34
+ end
35
+
36
+ delegate [:on, :subscribe] => :@terminator
37
+ end
38
+ end
39
+ end
40
+
@@ -0,0 +1,43 @@
1
+ require 'routemaster/middleware/root_post_only'
2
+ require 'routemaster/middleware/authenticate'
3
+ require 'routemaster/middleware/parse'
4
+ require 'routemaster/middleware/filter'
5
+ require 'routemaster/middleware/dirty'
6
+ require 'routemaster/middleware/cache'
7
+ require 'routemaster/drain/terminator'
8
+ require 'rack/builder'
9
+ require 'delegate'
10
+
11
+ module Routemaster
12
+ module Drain
13
+ # Rack application which authenticates, parses, filters, pushes to a dirty map,
14
+ # busts cache, schedules preemptive caching, and finally broadcasts events
15
+ # received from Routemaster.
16
+ #
17
+ # See the various corresponding middleware for details on operation:
18
+ # {Middleware::RootPostOnly}, {Middleware::Authenticate},
19
+ # {Middleware::Parse}, {Middleware::Filter}, {Middleware::Dirty},
20
+ # {Middleware::Cache} and {Terminator}.
21
+ #
22
+ class Caching
23
+ extend Forwardable
24
+
25
+ def initialize(options = {})
26
+ @terminator = terminator = Terminator.new
27
+ @app = ::Rack::Builder.new do
28
+ use Middleware::RootPostOnly
29
+ use Middleware::Authenticate, options
30
+ use Middleware::Parse
31
+ use Middleware::Filter, options
32
+ use Middleware::Dirty, options
33
+ use Middleware::Cache, options
34
+ run terminator
35
+ end
36
+ end
37
+
38
+ delegate :call => :@app
39
+ delegate [:on, :subscribe] => :@terminator
40
+ end
41
+ end
42
+ end
43
+
@@ -0,0 +1,43 @@
1
+ require 'routemaster/middleware/root_post_only'
2
+ require 'routemaster/middleware/authenticate'
3
+ require 'routemaster/middleware/parse'
4
+ require 'routemaster/middleware/filter'
5
+ require 'routemaster/middleware/dirty'
6
+ require 'routemaster/drain/terminator'
7
+ require 'rack/builder'
8
+ require 'delegate'
9
+
10
+ module Routemaster
11
+ module Drain
12
+ # Rack application which authenticates, parses, filters, pushes to a dirty map,
13
+ # and finally broadcasts events received from Routemaster.
14
+ #
15
+ # The dirty map can be obtained, for further processing, using
16
+ # `Dirty::Map.new`.
17
+ #
18
+ # See the various corresponding middleware for details on operation:
19
+ # {Middleware::RootPostOnly}, {Middleware::Authenticate},
20
+ # {Middleware::Parse}, {Middleware::Filter}, {Middleware::Dirty},
21
+ # and {Terminator}.
22
+ #
23
+ class Mapping
24
+ extend Forwardable
25
+
26
+ def initialize(options = {})
27
+ @terminator = terminator = Terminator.new
28
+ @app = ::Rack::Builder.new do
29
+ use Middleware::RootPostOnly
30
+ use Middleware::Authenticate, options
31
+ use Middleware::Parse
32
+ use Middleware::Filter, options
33
+ use Middleware::Dirty, options
34
+ run terminator
35
+ end
36
+ end
37
+
38
+ delegate :call => :@app
39
+ delegate [:on, :subscribe] => :@terminator
40
+ end
41
+ end
42
+ end
43
+