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 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: []