routemaster-drain 1.0.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 (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
+