async_cache 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,8 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem 'rspec-core', '~> 3.2.3'
6
+ gem 'rspec-expectations', '~> 3.2.1'
7
+ gem 'rspec-mocks', '~> 3.2.1'
8
+ gem 'pry', :require => false
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.
@@ -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,3 @@
1
+ module AsyncCache
2
+ VERSION = '0.0.1'
3
+ end
@@ -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
@@ -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'
@@ -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'
@@ -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: []