routemaster-drain 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.env.test +6 -0
- data/.gitignore +7 -0
- data/.gitmodules +3 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.travis.yml +16 -0
- data/.yardopts +6 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +122 -0
- data/Guardfile +10 -0
- data/LICENSE.txt +22 -0
- data/README.md +261 -0
- data/Rakefile +1 -0
- data/lib/routemaster/cache.rb +133 -0
- data/lib/routemaster/config.rb +57 -0
- data/lib/routemaster/dirty/filter.rb +49 -0
- data/lib/routemaster/dirty/map.rb +63 -0
- data/lib/routemaster/dirty/state.rb +33 -0
- data/lib/routemaster/drain.rb +5 -0
- data/lib/routemaster/drain/basic.rb +40 -0
- data/lib/routemaster/drain/caching.rb +43 -0
- data/lib/routemaster/drain/mapping.rb +43 -0
- data/lib/routemaster/drain/terminator.rb +31 -0
- data/lib/routemaster/fetcher.rb +63 -0
- data/lib/routemaster/jobs/cache.rb +12 -0
- data/lib/routemaster/jobs/cache_and_sweep.rb +16 -0
- data/lib/routemaster/middleware/authenticate.rb +59 -0
- data/lib/routemaster/middleware/cache.rb +29 -0
- data/lib/routemaster/middleware/dirty.rb +33 -0
- data/lib/routemaster/middleware/filter.rb +26 -0
- data/lib/routemaster/middleware/parse.rb +43 -0
- data/lib/routemaster/middleware/root_post_only.rb +18 -0
- data/lib/routemaster/redis_broker.rb +42 -0
- data/routemaster-drain.gemspec +28 -0
- data/spec/routemaster/cache_spec.rb +115 -0
- data/spec/routemaster/dirty/filter_spec.rb +77 -0
- data/spec/routemaster/dirty/map_spec.rb +122 -0
- data/spec/routemaster/dirty/state_spec.rb +41 -0
- data/spec/routemaster/drain/basic_spec.rb +37 -0
- data/spec/routemaster/drain/caching_spec.rb +47 -0
- data/spec/routemaster/drain/mapping_spec.rb +51 -0
- data/spec/routemaster/drain/terminator_spec.rb +61 -0
- data/spec/routemaster/fetcher_spec.rb +56 -0
- data/spec/routemaster/middleware/authenticate_spec.rb +59 -0
- data/spec/routemaster/middleware/cache_spec.rb +35 -0
- data/spec/routemaster/middleware/dirty_spec.rb +33 -0
- data/spec/routemaster/middleware/filter_spec.rb +35 -0
- data/spec/routemaster/middleware/parse_spec.rb +69 -0
- data/spec/routemaster/middleware/root_post_only_spec.rb +30 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/events.rb +9 -0
- data/spec/support/rack_test.rb +23 -0
- data/spec/support/uses_dotenv.rb +11 -0
- data/spec/support/uses_redis.rb +15 -0
- data/spec/support/uses_webmock.rb +12 -0
- data/test.rb +17 -0
- 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,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
|
+
|