async_cache 0.0.1
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/.gitignore +1 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +174 -0
- data/LICENSE +21 -0
- data/README.md +84 -0
- data/async_cache.gemspec +23 -0
- data/lib/async_cache/store.rb +118 -0
- data/lib/async_cache/version.rb +3 -0
- data/lib/async_cache/workers/active_job.rb +20 -0
- data/lib/async_cache/workers/base.rb +51 -0
- data/lib/async_cache/workers/sidekiq.rb +34 -0
- data/lib/async_cache.rb +23 -0
- data/spec/spec_helper.rb +11 -0
- data/spec/store_spec.rb +163 -0
- data/spec/workers/active_job_spec.rb +34 -0
- data/spec/workers/base_spec.rb +17 -0
- data/spec/workers/sidekiq_spec.rb +40 -0
- metadata +105 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA1:
|
|
3
|
+
metadata.gz: 52981fb455f8efb0f4995d5f14062b3fa0a5ff9e
|
|
4
|
+
data.tar.gz: 9c0a23d586fdb1fed9741112a1a2f226ce72aa40
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 37a1491c788a2e71b575b8c3124b52089c394090daed0838787d8c83d885c5c140d0769ba2b54349198c0a87d0e8dabd2b8fa847af392262f8e6b8b48e9ab625
|
|
7
|
+
data.tar.gz: c0c13544a791f15c1a942aa91d88b88099d8748b6de1b0519342b795ca6f634335aefdd76bfbe3cb2f14ae5bcef168a91c7782ee65633cfe0dc00d3eb3b53df1
|
data/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.DS_Store
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
async_cache (0.0.1)
|
|
5
|
+
sourcify (~> 0.5.0)
|
|
6
|
+
|
|
7
|
+
GEM
|
|
8
|
+
remote: https://rubygems.org/
|
|
9
|
+
specs:
|
|
10
|
+
actionmailer (4.2.4)
|
|
11
|
+
actionpack (= 4.2.4)
|
|
12
|
+
actionview (= 4.2.4)
|
|
13
|
+
activejob (= 4.2.4)
|
|
14
|
+
mail (~> 2.5, >= 2.5.4)
|
|
15
|
+
rails-dom-testing (~> 1.0, >= 1.0.5)
|
|
16
|
+
actionpack (4.2.4)
|
|
17
|
+
actionview (= 4.2.4)
|
|
18
|
+
activesupport (= 4.2.4)
|
|
19
|
+
rack (~> 1.6)
|
|
20
|
+
rack-test (~> 0.6.2)
|
|
21
|
+
rails-dom-testing (~> 1.0, >= 1.0.5)
|
|
22
|
+
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
|
23
|
+
actionview (4.2.4)
|
|
24
|
+
activesupport (= 4.2.4)
|
|
25
|
+
builder (~> 3.1)
|
|
26
|
+
erubis (~> 2.7.0)
|
|
27
|
+
rails-dom-testing (~> 1.0, >= 1.0.5)
|
|
28
|
+
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
|
29
|
+
activejob (4.2.4)
|
|
30
|
+
activesupport (= 4.2.4)
|
|
31
|
+
globalid (>= 0.3.0)
|
|
32
|
+
activemodel (4.2.4)
|
|
33
|
+
activesupport (= 4.2.4)
|
|
34
|
+
builder (~> 3.1)
|
|
35
|
+
activerecord (4.2.4)
|
|
36
|
+
activemodel (= 4.2.4)
|
|
37
|
+
activesupport (= 4.2.4)
|
|
38
|
+
arel (~> 6.0)
|
|
39
|
+
activesupport (4.2.4)
|
|
40
|
+
i18n (~> 0.7)
|
|
41
|
+
json (~> 1.7, >= 1.7.7)
|
|
42
|
+
minitest (~> 5.1)
|
|
43
|
+
thread_safe (~> 0.3, >= 0.3.4)
|
|
44
|
+
tzinfo (~> 1.1)
|
|
45
|
+
arel (6.0.3)
|
|
46
|
+
builder (3.2.2)
|
|
47
|
+
celluloid (0.17.2)
|
|
48
|
+
celluloid-essentials
|
|
49
|
+
celluloid-extras
|
|
50
|
+
celluloid-fsm
|
|
51
|
+
celluloid-pool
|
|
52
|
+
celluloid-supervision
|
|
53
|
+
timers (>= 4.1.1)
|
|
54
|
+
celluloid-essentials (0.20.5)
|
|
55
|
+
timers (>= 4.1.1)
|
|
56
|
+
celluloid-extras (0.20.5)
|
|
57
|
+
timers (>= 4.1.1)
|
|
58
|
+
celluloid-fsm (0.20.5)
|
|
59
|
+
timers (>= 4.1.1)
|
|
60
|
+
celluloid-pool (0.20.5)
|
|
61
|
+
timers (>= 4.1.1)
|
|
62
|
+
celluloid-supervision (0.20.5)
|
|
63
|
+
timers (>= 4.1.1)
|
|
64
|
+
coderay (1.1.0)
|
|
65
|
+
connection_pool (2.2.0)
|
|
66
|
+
diff-lcs (1.2.5)
|
|
67
|
+
erubis (2.7.0)
|
|
68
|
+
file-tail (1.1.0)
|
|
69
|
+
tins (~> 1.0)
|
|
70
|
+
globalid (0.3.6)
|
|
71
|
+
activesupport (>= 4.1.0)
|
|
72
|
+
hitimes (1.2.3)
|
|
73
|
+
i18n (0.7.0)
|
|
74
|
+
json (1.8.3)
|
|
75
|
+
loofah (2.0.3)
|
|
76
|
+
nokogiri (>= 1.5.9)
|
|
77
|
+
mail (2.6.3)
|
|
78
|
+
mime-types (>= 1.16, < 3)
|
|
79
|
+
method_source (0.8.2)
|
|
80
|
+
mime-types (2.6.2)
|
|
81
|
+
mini_portile (0.6.2)
|
|
82
|
+
minitest (5.8.2)
|
|
83
|
+
nokogiri (1.6.6.2)
|
|
84
|
+
mini_portile (~> 0.6.0)
|
|
85
|
+
pry (0.10.1)
|
|
86
|
+
coderay (~> 1.1.0)
|
|
87
|
+
method_source (~> 0.8.1)
|
|
88
|
+
slop (~> 3.4)
|
|
89
|
+
rack (1.6.4)
|
|
90
|
+
rack-test (0.6.3)
|
|
91
|
+
rack (>= 1.0)
|
|
92
|
+
rails (4.2.4)
|
|
93
|
+
actionmailer (= 4.2.4)
|
|
94
|
+
actionpack (= 4.2.4)
|
|
95
|
+
actionview (= 4.2.4)
|
|
96
|
+
activejob (= 4.2.4)
|
|
97
|
+
activemodel (= 4.2.4)
|
|
98
|
+
activerecord (= 4.2.4)
|
|
99
|
+
activesupport (= 4.2.4)
|
|
100
|
+
bundler (>= 1.3.0, < 2.0)
|
|
101
|
+
railties (= 4.2.4)
|
|
102
|
+
sprockets-rails
|
|
103
|
+
rails-deprecated_sanitizer (1.0.3)
|
|
104
|
+
activesupport (>= 4.2.0.alpha)
|
|
105
|
+
rails-dom-testing (1.0.7)
|
|
106
|
+
activesupport (>= 4.2.0.beta, < 5.0)
|
|
107
|
+
nokogiri (~> 1.6.0)
|
|
108
|
+
rails-deprecated_sanitizer (>= 1.0.1)
|
|
109
|
+
rails-html-sanitizer (1.0.2)
|
|
110
|
+
loofah (~> 2.0)
|
|
111
|
+
railties (4.2.4)
|
|
112
|
+
actionpack (= 4.2.4)
|
|
113
|
+
activesupport (= 4.2.4)
|
|
114
|
+
rake (>= 0.8.7)
|
|
115
|
+
thor (>= 0.18.1, < 2.0)
|
|
116
|
+
rake (10.4.2)
|
|
117
|
+
redis (3.2.1)
|
|
118
|
+
redis-namespace (1.5.2)
|
|
119
|
+
redis (~> 3.0, >= 3.0.4)
|
|
120
|
+
rspec-core (3.2.3)
|
|
121
|
+
rspec-support (~> 3.2.0)
|
|
122
|
+
rspec-expectations (3.2.1)
|
|
123
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
124
|
+
rspec-support (~> 3.2.0)
|
|
125
|
+
rspec-mocks (3.2.1)
|
|
126
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
127
|
+
rspec-support (~> 3.2.0)
|
|
128
|
+
rspec-support (3.2.2)
|
|
129
|
+
ruby2ruby (2.2.0)
|
|
130
|
+
ruby_parser (~> 3.1)
|
|
131
|
+
sexp_processor (~> 4.0)
|
|
132
|
+
ruby_parser (3.7.2)
|
|
133
|
+
sexp_processor (~> 4.1)
|
|
134
|
+
sexp_processor (4.6.0)
|
|
135
|
+
sidekiq (3.5.2)
|
|
136
|
+
celluloid (~> 0.17.2)
|
|
137
|
+
connection_pool (~> 2.2, >= 2.2.0)
|
|
138
|
+
json (~> 1.0)
|
|
139
|
+
redis (~> 3.2, >= 3.2.1)
|
|
140
|
+
redis-namespace (~> 1.5, >= 1.5.2)
|
|
141
|
+
slop (3.6.0)
|
|
142
|
+
sourcify (0.5.0)
|
|
143
|
+
file-tail (>= 1.0.5)
|
|
144
|
+
ruby2ruby (>= 1.2.5)
|
|
145
|
+
ruby_parser (>= 2.0.5)
|
|
146
|
+
sexp_processor (>= 3.0.5)
|
|
147
|
+
sprockets (3.4.0)
|
|
148
|
+
rack (> 1, < 3)
|
|
149
|
+
sprockets-rails (2.3.3)
|
|
150
|
+
actionpack (>= 3.0)
|
|
151
|
+
activesupport (>= 3.0)
|
|
152
|
+
sprockets (>= 2.8, < 4.0)
|
|
153
|
+
thor (0.19.1)
|
|
154
|
+
thread_safe (0.3.5)
|
|
155
|
+
timers (4.1.1)
|
|
156
|
+
hitimes
|
|
157
|
+
tins (1.8.1)
|
|
158
|
+
tzinfo (1.2.2)
|
|
159
|
+
thread_safe (~> 0.1)
|
|
160
|
+
|
|
161
|
+
PLATFORMS
|
|
162
|
+
ruby
|
|
163
|
+
|
|
164
|
+
DEPENDENCIES
|
|
165
|
+
async_cache!
|
|
166
|
+
pry
|
|
167
|
+
rails (~> 4.2.4)
|
|
168
|
+
rspec-core (~> 3.2.3)
|
|
169
|
+
rspec-expectations (~> 3.2.1)
|
|
170
|
+
rspec-mocks (~> 3.2.1)
|
|
171
|
+
sidekiq (~> 3.5.2)
|
|
172
|
+
|
|
173
|
+
BUNDLED WITH
|
|
174
|
+
1.10.6
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2015 Everlane Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# async_cache
|
|
2
|
+
|
|
3
|
+
Caching is great, but having to block your app while you refresh the cache isn't so great, especially when it takes a long time to regenerate that cache entry.
|
|
4
|
+
|
|
5
|
+
This outlines a strategy and provides a Rails-focused implementation for asynchronously refreshing caches while serving the stale version to users to maintain responsiveness.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Add the gem to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem 'async_cache'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then set up a store and fetch from it:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Storing entries in `Rails.cache` and enqueueing via ActiveJob
|
|
19
|
+
# (a Sidekiq worker is also available with additional
|
|
20
|
+
# deduplication features).
|
|
21
|
+
async_cache = AsyncCache::Store.new(
|
|
22
|
+
backend: Rails.cache,
|
|
23
|
+
worker: :active_job
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Then use it to do some heavy lifting asychronously
|
|
27
|
+
id = params[:id]
|
|
28
|
+
key = "big_model/#{id}"
|
|
29
|
+
version = BigModel.where(:id => id).pluck(:updated_at).first
|
|
30
|
+
|
|
31
|
+
async_cache.fetch(key, version, arguments: [id]) do |id|
|
|
32
|
+
BigModel.find(id).to_json
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Strategy
|
|
37
|
+
|
|
38
|
+
Async-cache follows a straightforward strategy for determining which action to take when a cache entry is fetched:
|
|
39
|
+
|
|
40
|
+
- If no entry is present it *synchronously* generates the entry, writes it to the cache, and returns it.
|
|
41
|
+
- If an old version of the entry is present it enqueues an asynchronous job to refresh the entry and returns the old version.
|
|
42
|
+
- If an up-to-date version of the entry is present it serves that.
|
|
43
|
+
|
|
44
|
+
The implementation includes a few nuances to the strategy: such as checking if workers are running and allowing clients to specify that it should always synchronously-regenerate (useful in things like CMSes where you always want to immediately render the latest version to the user editing it).
|
|
45
|
+
|
|
46
|
+
### Cache Structure
|
|
47
|
+
|
|
48
|
+
Async-caching requires a different cache structure than traditional caching.
|
|
49
|
+
|
|
50
|
+
In traditional caching—here using Rails idioms—the cache for the model "Thing" would look like the following:
|
|
51
|
+
|
|
52
|
+
- A cache key, such as `things/123-20151210063911000000000`, where the key is comprised of the name of the model, the ID of the instance in question, and the last-modified time (`updated_at`) as an integer
|
|
53
|
+
- A cache value containing the actual rendered data for that model instance
|
|
54
|
+
|
|
55
|
+
In async-caching the cache must be comprised of three parts:
|
|
56
|
+
|
|
57
|
+
- A cache locator, such as `things/123`
|
|
58
|
+
- A version, such as `20151210063911000000000` (using the last-modified time works perfectly for this)
|
|
59
|
+
- The cache value
|
|
60
|
+
|
|
61
|
+
The locator must be constant in async-caching so that we can always retrieve a cache record (version and value) for the given locator. The cache record is then not just a value, but also has the metadata of the version which describes which version-of-the-locator the value applies to. By having this version metadata we're able to determine whether the cache is up-to-date or out-of-date.
|
|
62
|
+
|
|
63
|
+
##### Example
|
|
64
|
+
|
|
65
|
+
The following is a simplified example of how values would be cached in Rails in the traditional and async structures:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
value = compute_some_expensive_value
|
|
69
|
+
|
|
70
|
+
# Traditional
|
|
71
|
+
key = "things/#{thing.id}-#{thing.updated_at.to_i}"
|
|
72
|
+
|
|
73
|
+
Rails.cache.write key, value
|
|
74
|
+
|
|
75
|
+
# Async
|
|
76
|
+
locator = "things/#{thing.id}"
|
|
77
|
+
version = thing.updated_at.to_i
|
|
78
|
+
|
|
79
|
+
Rails.cache.write key, [version, value]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
Released under the MIT license, see [LICENSE](LICENSE) for details.
|
data/async_cache.gemspec
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
lib = File.join File.dirname(__FILE__), 'lib'
|
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
3
|
+
require 'async_cache/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |s|
|
|
6
|
+
s.name = 'async_cache'
|
|
7
|
+
s.version = AsyncCache::VERSION
|
|
8
|
+
s.authors = ['Adam Derewecki', 'Dirk Gadsden']
|
|
9
|
+
s.email = ['derewecki@gmail.com', 'dirk@esherido.com']
|
|
10
|
+
s.summary = 'Pattern and library for implementing asynchronous caching'
|
|
11
|
+
s.homepage = 'https://github.com/Everlane/async_cache'
|
|
12
|
+
s.license = 'MIT'
|
|
13
|
+
|
|
14
|
+
s.required_ruby_version = '>= 1.9.3'
|
|
15
|
+
|
|
16
|
+
s.add_dependency 'sourcify', '~> 0.5.0'
|
|
17
|
+
|
|
18
|
+
s.add_development_dependency 'rails', '~> 4.2.4'
|
|
19
|
+
s.add_development_dependency 'sidekiq', '~> 3.5.2'
|
|
20
|
+
|
|
21
|
+
s.files = `git ls-files`.split("\n")
|
|
22
|
+
s.test_files = `git ls-files -- test/*`.split("\n")
|
|
23
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module AsyncCache
|
|
2
|
+
class Store
|
|
3
|
+
attr_accessor :backend, :worker_klass
|
|
4
|
+
|
|
5
|
+
def initialize(opts = {})
|
|
6
|
+
@worker_klass =
|
|
7
|
+
if opts[:worker_klass]
|
|
8
|
+
opts[:worker_klass]
|
|
9
|
+
elsif opts[:worker]
|
|
10
|
+
AsyncCache::Workers.worker_for_name opts[:worker]
|
|
11
|
+
else
|
|
12
|
+
raise ArgumentError, 'Must have a :worker_klass or :worker option'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@backend = opts[:backend] || AsyncCache.backend
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fetch(locator, version, options = {}, &block)
|
|
19
|
+
options = options.dup # Duplicate to avoid side effects
|
|
20
|
+
version = version.to_i # Versions must *always* be convertible to integers
|
|
21
|
+
|
|
22
|
+
# Expires-in must be an integer if present, nil if not
|
|
23
|
+
expires_in = options[:expires_in] ? options[:expires_in].to_i : nil
|
|
24
|
+
|
|
25
|
+
block_arguments = check_arguments(options.delete(:arguments) || [])
|
|
26
|
+
|
|
27
|
+
# Serialize arguments into the full cache key
|
|
28
|
+
key = ActiveSupport::Cache.expand_cache_key Array.wrap(locator) + block_arguments
|
|
29
|
+
|
|
30
|
+
cached_data, cached_version = @backend.read key
|
|
31
|
+
|
|
32
|
+
strategy = determine_strategy(
|
|
33
|
+
:has_cached_data => !!cached_data,
|
|
34
|
+
:needs_regen => version > (cached_version || 0),
|
|
35
|
+
:synchronous_regen => options[:synchronous_regen]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
context = {
|
|
39
|
+
:key => key,
|
|
40
|
+
:version => version,
|
|
41
|
+
:expires_in => expires_in,
|
|
42
|
+
:block => block,
|
|
43
|
+
:arguments => block_arguments
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
case strategy
|
|
47
|
+
when :generate
|
|
48
|
+
return generate_and_cache context
|
|
49
|
+
|
|
50
|
+
when :enqueue
|
|
51
|
+
enqueue_generation context
|
|
52
|
+
return cached_data
|
|
53
|
+
|
|
54
|
+
when :current
|
|
55
|
+
return cached_data
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def determine_strategy(has_cached_data:, needs_regen:, synchronous_regen:)
|
|
60
|
+
case
|
|
61
|
+
when !has_cached_data
|
|
62
|
+
# Not present at all
|
|
63
|
+
:generate
|
|
64
|
+
when needs_regen && synchronous_regen
|
|
65
|
+
# Caller has indicated we should synchronously regenerate
|
|
66
|
+
:generate
|
|
67
|
+
when needs_regen && !worker_klass.has_workers?
|
|
68
|
+
# No workers available to regnerate, so do it ourselves; we'll log a
|
|
69
|
+
# warning message that we can alert on
|
|
70
|
+
AsyncCache.logger.warn "No workers running to handle AsyncCache jobs"
|
|
71
|
+
:generate
|
|
72
|
+
when needs_regen
|
|
73
|
+
:enqueue
|
|
74
|
+
else
|
|
75
|
+
:current
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def generate_and_cache(key:, version:, expires_in:, block:, arguments:)
|
|
80
|
+
# Mimic the destruction-of-scope behavior of the worker in development
|
|
81
|
+
# so it will *fail* for developers if they try to depend upon scope
|
|
82
|
+
block = eval(block.to_source)
|
|
83
|
+
|
|
84
|
+
data = block.call(*arguments)
|
|
85
|
+
|
|
86
|
+
entry = [data, version]
|
|
87
|
+
@backend.write key, entry, :expires_in => expires_in
|
|
88
|
+
|
|
89
|
+
return data
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def enqueue_generation(key:, version:, expires_in:, block:, arguments:)
|
|
93
|
+
worker_klass.enqueue_async_job(
|
|
94
|
+
key: key,
|
|
95
|
+
version: version,
|
|
96
|
+
expires_in: expires_in,
|
|
97
|
+
block: block.to_source,
|
|
98
|
+
arguments: arguments
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Ensure the arguments are primitives
|
|
105
|
+
def check_arguments arguments
|
|
106
|
+
arguments.each_with_index do |argument, index|
|
|
107
|
+
next if argument.is_a? Numeric
|
|
108
|
+
next if argument.is_a? String
|
|
109
|
+
next if argument.is_a? Symbol
|
|
110
|
+
|
|
111
|
+
raise ArgumentError, "Cannot send complex data for block argument #{index + 1}: #{argument.class.name}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
arguments
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
end # class Store
|
|
118
|
+
end # class AsyncCache
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require 'active_job'
|
|
2
|
+
|
|
3
|
+
module AsyncCache
|
|
4
|
+
module Workers
|
|
5
|
+
class ActiveJobWorker < ActiveJob::Base
|
|
6
|
+
include Base
|
|
7
|
+
|
|
8
|
+
def self.has_workers?
|
|
9
|
+
# ActiveJob doesn't provide a way to see if worker processes are
|
|
10
|
+
# running so we just assume that they are
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.enqueue_async_job(key:, version:, expires_in:, block:, arguments:)
|
|
15
|
+
self.perform_later key, version, expires_in, arguments, block
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
end # class ActiveJobWorker
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module AsyncCache
|
|
2
|
+
module Workers
|
|
3
|
+
def self.worker_for_name(name)
|
|
4
|
+
case name
|
|
5
|
+
when :sidekiq
|
|
6
|
+
require 'async_cache/workers/sidekiq'
|
|
7
|
+
AsyncCache::Workers::SidekiqWorker
|
|
8
|
+
when :active_job
|
|
9
|
+
require 'async_cache/workers/active_job'
|
|
10
|
+
AsyncCache::Workers::ActiveJobWorker
|
|
11
|
+
else
|
|
12
|
+
raise "Worker not found: #{name.inspect}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Base
|
|
17
|
+
# Abstract public interface to workers that process AsyncCache jobs
|
|
18
|
+
def self.has_workers?
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.enqueue_async_job(key:, version:, expires_in:, block:, arguments:)
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# key - String or array cache key computed by `AsyncCache`
|
|
27
|
+
# version - Monotonically increasing integer indicating the version
|
|
28
|
+
# of the resource being cached
|
|
29
|
+
# expires_in - Optional expiration to pass to the cache store
|
|
30
|
+
# block_arguments - Arguments with which to call the block
|
|
31
|
+
# block_source - Ruby source to evaluate to produce the value
|
|
32
|
+
def perform key, version, expires_in, block_arguments, block_source
|
|
33
|
+
t0 = Time.now
|
|
34
|
+
|
|
35
|
+
_cached_data, cached_version = backend.read key
|
|
36
|
+
return unless version > (cached_version || 0)
|
|
37
|
+
|
|
38
|
+
value = [eval(block_source).call(*block_arguments), version]
|
|
39
|
+
|
|
40
|
+
backend.write key, value, :expires_in => expires_in
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def backend
|
|
46
|
+
AsyncCache.backend
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
end # module Base
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require 'sidekiq'
|
|
2
|
+
require 'sidekiq/api'
|
|
3
|
+
|
|
4
|
+
module AsyncCache
|
|
5
|
+
module Workers
|
|
6
|
+
class SidekiqWorker
|
|
7
|
+
include Base
|
|
8
|
+
include Sidekiq::Worker
|
|
9
|
+
|
|
10
|
+
# Only allow one job per set of arguments to ever be in the queue
|
|
11
|
+
sidekiq_options :unique => :until_executed
|
|
12
|
+
|
|
13
|
+
# Use the Sidekiq API to see if there are worker processes available to
|
|
14
|
+
# handle the async cache jobs queue.
|
|
15
|
+
def self.has_workers?
|
|
16
|
+
target_queue = self.sidekiq_options['queue'].to_s
|
|
17
|
+
|
|
18
|
+
processes = Sidekiq::ProcessSet.new.to_a
|
|
19
|
+
queues_being_processed = processes.flat_map { |p| p['queues'] }
|
|
20
|
+
|
|
21
|
+
if queues_being_processed.include? target_queue
|
|
22
|
+
true
|
|
23
|
+
else
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.enqueue_async_job(key:, version:, expires_in:, block:, arguments:)
|
|
29
|
+
self.perform_async key, version, expires_in, arguments, block
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
end # class SidekiqWorker
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/async_cache.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require 'sourcify'
|
|
2
|
+
|
|
3
|
+
module AsyncCache
|
|
4
|
+
class << self
|
|
5
|
+
def backend
|
|
6
|
+
@backend || Rails.cache
|
|
7
|
+
end
|
|
8
|
+
def backend=(backend)
|
|
9
|
+
@backend = backend
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def logger
|
|
13
|
+
@logger || Rails.logger
|
|
14
|
+
end
|
|
15
|
+
def logger=(logger)
|
|
16
|
+
@logger = logger
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
require 'async_cache/version'
|
|
22
|
+
require 'async_cache/store'
|
|
23
|
+
require 'async_cache/workers/base'
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require 'bundler/setup'
|
|
2
|
+
|
|
3
|
+
require 'rails'
|
|
4
|
+
require 'sidekiq'
|
|
5
|
+
require 'sidekiq/testing'
|
|
6
|
+
|
|
7
|
+
Sidekiq::Testing.inline!
|
|
8
|
+
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
|
9
|
+
Rails.logger = Logger.new($stdout).tap { |log| log.level = Logger::ERROR }
|
|
10
|
+
|
|
11
|
+
require 'async_cache'
|
data/spec/store_spec.rb
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'async_cache/workers/sidekiq'
|
|
3
|
+
|
|
4
|
+
describe AsyncCache::Store do
|
|
5
|
+
|
|
6
|
+
subject do
|
|
7
|
+
AsyncCache::Store.new(
|
|
8
|
+
backend: Rails.cache,
|
|
9
|
+
worker: :sidekiq
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
context 'caching' do
|
|
14
|
+
def stub_not_present(key)
|
|
15
|
+
expect(Rails.cache).to receive(:read).with(key).and_return(nil)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def stub_present(key, value)
|
|
19
|
+
expect(Rails.cache).to receive(:read).with(key).and_return([value, 0])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
before(:each) do
|
|
23
|
+
Rails.cache.clear
|
|
24
|
+
|
|
25
|
+
@key = 'a key'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "synchronously calls #fetch if entry isn't present" do
|
|
29
|
+
stub_not_present @key
|
|
30
|
+
|
|
31
|
+
version = Time.now.to_i
|
|
32
|
+
expires_in = 1.minute
|
|
33
|
+
|
|
34
|
+
# Expect another synchronous call with a block to compute the value
|
|
35
|
+
expect(Rails.cache).to receive(:write).with(@key, ['something', version], {:expires_in => expires_in}).and_call_original
|
|
36
|
+
|
|
37
|
+
fetched_value = subject.fetch(@key, version, :expires_in => expires_in) { 'something' }
|
|
38
|
+
|
|
39
|
+
expect(fetched_value).to eql 'something'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'returns the stale value and enqueues the worker if entry is present and timestamp is changed' do
|
|
43
|
+
# It will try to check that workers are present, so we need to make that
|
|
44
|
+
# check be a no-op
|
|
45
|
+
allow(subject.worker_klass).to receive(:has_workers?).and_return(true)
|
|
46
|
+
|
|
47
|
+
old_value = 'old!'
|
|
48
|
+
timestamp = Time.now
|
|
49
|
+
expires_in = 10.days.to_i
|
|
50
|
+
arguments = [1]
|
|
51
|
+
|
|
52
|
+
# Cache key is composed of *both* the key and the arguments given to the
|
|
53
|
+
# block since those arguments determine the output of the block
|
|
54
|
+
cache_key = ActiveSupport::Cache.expand_cache_key([@key] + arguments)
|
|
55
|
+
|
|
56
|
+
stub_present cache_key, 'old!'
|
|
57
|
+
|
|
58
|
+
# Expecting it to call the worker with the block to compute the new value
|
|
59
|
+
expect(subject.worker_klass).to receive(:enqueue_async_job).with(
|
|
60
|
+
key: cache_key,
|
|
61
|
+
version: timestamp.to_i,
|
|
62
|
+
expires_in: expires_in,
|
|
63
|
+
block: anything,
|
|
64
|
+
arguments: arguments
|
|
65
|
+
) do |opts|
|
|
66
|
+
block_source = opts[:block]
|
|
67
|
+
block_arguments = opts[:arguments]
|
|
68
|
+
|
|
69
|
+
# Check the the block behaves correctly
|
|
70
|
+
expect(eval(block_source).call(*block_arguments)).to eql 2
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
fetched_value = subject.fetch(@key, timestamp, :expires_in => expires_in, :arguments => arguments) do |private_argument|
|
|
74
|
+
private_argument * 2
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Check that it immediately returns the stale value
|
|
78
|
+
expect(fetched_value).to eql old_value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "returns the current value if timestamp isn't changed" do
|
|
82
|
+
stub_present @key, 'value'
|
|
83
|
+
|
|
84
|
+
timestamp = 0 # `stub_present` returns a timestamp of 0
|
|
85
|
+
|
|
86
|
+
expect(subject.fetch(@key, timestamp, :expires_in => 1.minute) { 'bad!' }).to eql 'value'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
end # context caching
|
|
90
|
+
|
|
91
|
+
describe '#determine_strategy' do
|
|
92
|
+
it 'always generates when no data is cached' do
|
|
93
|
+
# Combinations of needs-regen and synchronous-regen arguments
|
|
94
|
+
combos = [
|
|
95
|
+
[true, true],
|
|
96
|
+
[true, false],
|
|
97
|
+
[false, false],
|
|
98
|
+
[false, true]
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
combos.each do |(needs_regen, synchronous_regen)|
|
|
102
|
+
expect(
|
|
103
|
+
subject.determine_strategy(
|
|
104
|
+
has_cached_data: false,
|
|
105
|
+
needs_regen: needs_regen,
|
|
106
|
+
synchronous_regen: synchronous_regen
|
|
107
|
+
)
|
|
108
|
+
).to eql :generate
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
context 'when needing regeneration' do
|
|
113
|
+
let(:has_cached_data) { true }
|
|
114
|
+
let(:needs_regen) { true }
|
|
115
|
+
|
|
116
|
+
it 'generates when told to synchronously-regenerate' do
|
|
117
|
+
expect(
|
|
118
|
+
subject.determine_strategy(
|
|
119
|
+
has_cached_data: has_cached_data,
|
|
120
|
+
needs_regen: needs_regen,
|
|
121
|
+
synchronous_regen: true
|
|
122
|
+
)
|
|
123
|
+
).to eql :generate
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'enqueues when not told to synchronously-regenerate' do
|
|
127
|
+
allow(subject.worker_klass).to receive(:has_workers?).and_return(true)
|
|
128
|
+
|
|
129
|
+
expect(
|
|
130
|
+
subject.determine_strategy(
|
|
131
|
+
has_cached_data: has_cached_data,
|
|
132
|
+
needs_regen: needs_regen,
|
|
133
|
+
synchronous_regen: false
|
|
134
|
+
)
|
|
135
|
+
).to eql :enqueue
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'generates instead of enqueueing when workers are not available' do
|
|
139
|
+
allow(subject.worker_klass).to receive(:has_workers?).and_return(false)
|
|
140
|
+
|
|
141
|
+
expect(
|
|
142
|
+
subject.determine_strategy(
|
|
143
|
+
has_cached_data: has_cached_data,
|
|
144
|
+
needs_regen: needs_regen,
|
|
145
|
+
synchronous_regen: false
|
|
146
|
+
)
|
|
147
|
+
).to eql :generate
|
|
148
|
+
end
|
|
149
|
+
end # context when needing regeneration
|
|
150
|
+
|
|
151
|
+
it "returns current value if it doesn't need regeneration" do
|
|
152
|
+
expect(
|
|
153
|
+
subject.determine_strategy(
|
|
154
|
+
has_cached_data: true,
|
|
155
|
+
needs_regen: false,
|
|
156
|
+
synchronous_regen: false
|
|
157
|
+
)
|
|
158
|
+
).to eql :current
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
end # describe #determine_strategy
|
|
162
|
+
|
|
163
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'async_cache/workers/active_job'
|
|
3
|
+
|
|
4
|
+
describe AsyncCache::Workers::ActiveJobWorker do
|
|
5
|
+
subject do
|
|
6
|
+
AsyncCache::Workers::ActiveJobWorker
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
describe '::has_workers?' do
|
|
10
|
+
it 'returns true' do
|
|
11
|
+
expect(subject.send :has_workers?).to eql true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe '::enqueue_async_job' do
|
|
16
|
+
it 'enqueues a job' do
|
|
17
|
+
key = 'abc123'
|
|
18
|
+
version = 456
|
|
19
|
+
expires_in = 789
|
|
20
|
+
block = 'proc { }'
|
|
21
|
+
arguments = []
|
|
22
|
+
|
|
23
|
+
expect(subject).to receive(:perform_later).with(key, version, expires_in, arguments, block)
|
|
24
|
+
|
|
25
|
+
subject.enqueue_async_job(
|
|
26
|
+
key: key,
|
|
27
|
+
version: version,
|
|
28
|
+
expires_in: expires_in,
|
|
29
|
+
block: block,
|
|
30
|
+
arguments: arguments
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe AsyncCache::Workers do
|
|
4
|
+
subject do
|
|
5
|
+
AsyncCache::Workers
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
describe '::worker_for_name' do
|
|
9
|
+
it 'finds the Sidekiq worker' do
|
|
10
|
+
expect(subject.worker_for_name :sidekiq).to eql AsyncCache::Workers::SidekiqWorker
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'finds the ActiveJob worker' do
|
|
14
|
+
expect(subject.worker_for_name :active_job).to eql AsyncCache::Workers::ActiveJobWorker
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'async_cache/workers/sidekiq'
|
|
3
|
+
|
|
4
|
+
describe AsyncCache::Workers::SidekiqWorker do
|
|
5
|
+
subject do
|
|
6
|
+
AsyncCache::Workers::SidekiqWorker
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
describe '::has_workers?' do
|
|
10
|
+
it 'returns false if no Sidekiq queues are available' do
|
|
11
|
+
allow(subject).to receive(:sidekiq_options).and_return({'queue' => 'good_queue'})
|
|
12
|
+
|
|
13
|
+
allow_any_instance_of(Sidekiq::ProcessSet).to receive(:to_a).and_return([
|
|
14
|
+
{ 'queues' => ['bad_queue'] }
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
expect(subject.send :has_workers?).to eql false
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe '::enqueue_async_job' do
|
|
22
|
+
it 'enqueues a job' do
|
|
23
|
+
key = 'abc123'
|
|
24
|
+
version = 456
|
|
25
|
+
expires_in = 789
|
|
26
|
+
block = 'proc { }'
|
|
27
|
+
arguments = []
|
|
28
|
+
|
|
29
|
+
expect(subject).to receive(:perform_async).with(key, version, expires_in, arguments, block)
|
|
30
|
+
|
|
31
|
+
subject.enqueue_async_job(
|
|
32
|
+
key: key,
|
|
33
|
+
version: version,
|
|
34
|
+
expires_in: expires_in,
|
|
35
|
+
block: block,
|
|
36
|
+
arguments: arguments
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: async_cache
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Adam Derewecki
|
|
8
|
+
- Dirk Gadsden
|
|
9
|
+
autorequire:
|
|
10
|
+
bindir: bin
|
|
11
|
+
cert_chain: []
|
|
12
|
+
date: 2015-12-15 00:00:00.000000000 Z
|
|
13
|
+
dependencies:
|
|
14
|
+
- !ruby/object:Gem::Dependency
|
|
15
|
+
name: sourcify
|
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
|
17
|
+
requirements:
|
|
18
|
+
- - "~>"
|
|
19
|
+
- !ruby/object:Gem::Version
|
|
20
|
+
version: 0.5.0
|
|
21
|
+
type: :runtime
|
|
22
|
+
prerelease: false
|
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
24
|
+
requirements:
|
|
25
|
+
- - "~>"
|
|
26
|
+
- !ruby/object:Gem::Version
|
|
27
|
+
version: 0.5.0
|
|
28
|
+
- !ruby/object:Gem::Dependency
|
|
29
|
+
name: rails
|
|
30
|
+
requirement: !ruby/object:Gem::Requirement
|
|
31
|
+
requirements:
|
|
32
|
+
- - "~>"
|
|
33
|
+
- !ruby/object:Gem::Version
|
|
34
|
+
version: 4.2.4
|
|
35
|
+
type: :development
|
|
36
|
+
prerelease: false
|
|
37
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
38
|
+
requirements:
|
|
39
|
+
- - "~>"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: 4.2.4
|
|
42
|
+
- !ruby/object:Gem::Dependency
|
|
43
|
+
name: sidekiq
|
|
44
|
+
requirement: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - "~>"
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 3.5.2
|
|
49
|
+
type: :development
|
|
50
|
+
prerelease: false
|
|
51
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - "~>"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: 3.5.2
|
|
56
|
+
description:
|
|
57
|
+
email:
|
|
58
|
+
- derewecki@gmail.com
|
|
59
|
+
- dirk@esherido.com
|
|
60
|
+
executables: []
|
|
61
|
+
extensions: []
|
|
62
|
+
extra_rdoc_files: []
|
|
63
|
+
files:
|
|
64
|
+
- ".gitignore"
|
|
65
|
+
- Gemfile
|
|
66
|
+
- Gemfile.lock
|
|
67
|
+
- LICENSE
|
|
68
|
+
- README.md
|
|
69
|
+
- async_cache.gemspec
|
|
70
|
+
- lib/async_cache.rb
|
|
71
|
+
- lib/async_cache/store.rb
|
|
72
|
+
- lib/async_cache/version.rb
|
|
73
|
+
- lib/async_cache/workers/active_job.rb
|
|
74
|
+
- lib/async_cache/workers/base.rb
|
|
75
|
+
- lib/async_cache/workers/sidekiq.rb
|
|
76
|
+
- spec/spec_helper.rb
|
|
77
|
+
- spec/store_spec.rb
|
|
78
|
+
- spec/workers/active_job_spec.rb
|
|
79
|
+
- spec/workers/base_spec.rb
|
|
80
|
+
- spec/workers/sidekiq_spec.rb
|
|
81
|
+
homepage: https://github.com/Everlane/async_cache
|
|
82
|
+
licenses:
|
|
83
|
+
- MIT
|
|
84
|
+
metadata: {}
|
|
85
|
+
post_install_message:
|
|
86
|
+
rdoc_options: []
|
|
87
|
+
require_paths:
|
|
88
|
+
- lib
|
|
89
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: 1.9.3
|
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '0'
|
|
99
|
+
requirements: []
|
|
100
|
+
rubyforge_project:
|
|
101
|
+
rubygems_version: 2.4.5.1
|
|
102
|
+
signing_key:
|
|
103
|
+
specification_version: 4
|
|
104
|
+
summary: Pattern and library for implementing asynchronous caching
|
|
105
|
+
test_files: []
|