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.
- 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
|
+
|