sidekiq_smart_cache 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +134 -0
- data/Rakefile +33 -0
- data/lib/sidekiq_smart_cache/interlock.rb +27 -0
- data/lib/sidekiq_smart_cache/model.rb +109 -0
- data/lib/sidekiq_smart_cache/promise.rb +120 -0
- data/lib/sidekiq_smart_cache/redis.rb +94 -0
- data/lib/sidekiq_smart_cache/result.rb +38 -0
- data/lib/sidekiq_smart_cache/version.rb +3 -0
- data/lib/sidekiq_smart_cache/worker.rb +26 -0
- data/lib/sidekiq_smart_cache.rb +30 -0
- data/lib/tasks/sidekiq_smart_cache_tasks.rake +4 -0
- metadata +84 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 0bfe51b0884c74e8f916b2e07a756b14fa41810dc3c137225bc978ac6a41ab81
|
4
|
+
data.tar.gz: e5c34d6795f47aa5b4ba17e7ca538ada975cdef8406c113a8107b205cd540955
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b66ae306d585ac20cf6b0e5d47bd634cb1fb36ba4e51b041c33eb3f77471190405cb4a64face8a53b578425e85f9bf85a345fd869f262ce91516941fab17e168
|
7
|
+
data.tar.gz: 2fc77d0ad12e9c317dddb1d6a8358256fb86f74330ed1fde78a71e61fa9914a6dd55ff8406a57770de8b0476615ff0ac7ceba6ddf3dc0f93d999c3007d896249
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2020 Bill Kirtley
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,134 @@
|
|
1
|
+
# SidekiqSmartCache
|
2
|
+
|
3
|
+
Generate and cache objects using sidekiq, with thundering herd prevention and client timeouts.
|
4
|
+
|
5
|
+
Say you have a resource that's expensive to calculate (a heavy database query) and would like to use it in a web page.
|
6
|
+
|
7
|
+
But you'd rather the web page show an empty value (or a "check back later" placeholder) than take too long to render.
|
8
|
+
|
9
|
+
And you'd like to ensure that if there are multiple actions requesting that page at once, that the database only has to fill the query once. (You want to prevent a [thundering herd problem](https://en.wikipedia.org/wiki/Thundering_herd_problem))
|
10
|
+
|
11
|
+
## Usage
|
12
|
+
|
13
|
+
Say your `Widget` class has a method `do_a_thing` that is sometimes quite expensive to calculate, but returns a value you'd like to include in a web page, as long as the value can be made available in five seconds. Once calculated, the value is valid for ten minutes, and all renderings of the page can show that same value.
|
14
|
+
|
15
|
+
In the controller:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
promise = SidekiqSmartCache::Promise.new(klass: Widget, method: :do_a_thing, expires_in: 10 * 60)
|
19
|
+
if promise.ready_within?(5.seconds)
|
20
|
+
# Render using the generated thing
|
21
|
+
@thing = promise.value
|
22
|
+
else
|
23
|
+
# Render some "try again in a bit" presentation
|
24
|
+
end
|
25
|
+
```
|
26
|
+
|
27
|
+
If no other workers are currently calculating the value, this will queue up a sidekiq job to call `Widget.do_a_thing`. If other workers are currently calculating, it will not start another, preventing the thundering herd.
|
28
|
+
|
29
|
+
Then it will wait as much as 5 seconds for a value to be returned.
|
30
|
+
|
31
|
+
If in the end, value is nil, offer a default or "try again" presentation to the user.
|
32
|
+
|
33
|
+
Also supported: passing arguments, calling an instance method on an object in the database, and explicitly naming your cache tag.
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
SidekiqSmartCache::Promise.new(
|
37
|
+
object: widget, method: :do_an_instance_thing, args: ['fun', 12],
|
38
|
+
expires_in: 10.minutes
|
39
|
+
)
|
40
|
+
```
|
41
|
+
|
42
|
+
If your results are (or contain) other than the YAML.safe_load list (TrueClass, FalseClass, NilClass, Numeric, String, Array, Hash) plus Symbol and Time, configure with an initializer:
|
43
|
+
|
44
|
+
```
|
45
|
+
SidekiqSmartCache.allowed_classes = [MyFavoriteClass]
|
46
|
+
```
|
47
|
+
|
48
|
+
## Installation
|
49
|
+
Add this line to your application's Gemfile:
|
50
|
+
|
51
|
+
```ruby
|
52
|
+
gem 'sidekiq_smart_cache'
|
53
|
+
```
|
54
|
+
|
55
|
+
And then execute:
|
56
|
+
```bash
|
57
|
+
$ bundle
|
58
|
+
```
|
59
|
+
|
60
|
+
Or install it yourself as:
|
61
|
+
```bash
|
62
|
+
$ gem install sidekiq_smart_cache
|
63
|
+
```
|
64
|
+
|
65
|
+
Add an initializer:
|
66
|
+
```ruby
|
67
|
+
Rails.configuration.to_prepare do
|
68
|
+
SidekiqSmartCache.logger = Rails.logger
|
69
|
+
SidekiqSmartCache.redis_pool = Sidekiq.redis_pool
|
70
|
+
SidekiqSmartCache.cache_prefix = ENV['RAILS_CACHE_ID']
|
71
|
+
end
|
72
|
+
```
|
73
|
+
|
74
|
+
## Model mix-in
|
75
|
+
|
76
|
+
Let's assume your User class has a active_user_count class method that is expensive to calculate
|
77
|
+
|
78
|
+
Declaring `make_class_action_cacheable :active_user_count` will add:
|
79
|
+
|
80
|
+
* `User.active_user_count_without_caching` - always performs the full calculation synchronously, doesn't touch the cache.
|
81
|
+
* `User.refresh_active_user_count` - always performs the full calculation synchronously, populating the cache with the new value.
|
82
|
+
* `User.active_user_count` (the original name) - will now fetch from the cache, only recalculating if the cache is absent or stale.
|
83
|
+
* `User.active_user_count_if_available` will now fetch from the cache but not recalculate, returning nil if the cache is absent or stale.
|
84
|
+
* `User.active_user_count_cache_tag` - the cache tag used to store calculated results. Probably not useful to clients.
|
85
|
+
* `User.active_user_count_promise` - returns a Promise object
|
86
|
+
|
87
|
+
Call `promise.fetch(5.seconds)` to wait up to five seconds for a new value, returning nil on timeout.
|
88
|
+
|
89
|
+
Call `promise.fetch!(5.seconds)` to wait up to five seconds for a new value, raising `SidekiqSmartCache::TimeoutError` on timeout.
|
90
|
+
|
91
|
+
Use <tt>make_instance_action_cacheable</tt> for the equivalent set of instance methods.
|
92
|
+
Your models must respond to <tt>to_param</tt> with a unique string suitable for constructing a cache key.
|
93
|
+
The class must respond to <tt>find</tt> and return an object that responds to the method.
|
94
|
+
|
95
|
+
## Testing
|
96
|
+
|
97
|
+
# Setup
|
98
|
+
```
|
99
|
+
bundle install
|
100
|
+
pushd test/dummy
|
101
|
+
bundle exec rake db:create db:migrate db:test:prepare
|
102
|
+
popd
|
103
|
+
bundle exec bin/test
|
104
|
+
```
|
105
|
+
# Tidy
|
106
|
+
|
107
|
+
The test database is using sqlite, so `rm test/dummy/db/*.sqlite3` is a sincere way to reset context.
|
108
|
+
|
109
|
+
# Debugging
|
110
|
+
Test a single file:
|
111
|
+
```
|
112
|
+
bin/test test/model_test.rb -n test_cache_tag_generation
|
113
|
+
```
|
114
|
+
|
115
|
+
Open a SQL shell on a database:
|
116
|
+
```
|
117
|
+
sqlite3 test/dummy/db/test.sqlite3
|
118
|
+
```
|
119
|
+
|
120
|
+
Test with a specific Sidekiq version:
|
121
|
+
```
|
122
|
+
BUNDLE_GEMFILE=gemfiles/Gemfile-sidekiq7 bundle exec bin/test
|
123
|
+
```
|
124
|
+
|
125
|
+
Specify a non-default redis for both sidekiq and the cache:
|
126
|
+
```
|
127
|
+
REDIS_PROVIDER=REDIS_URL REDIS_URL=redis://localhost:6380 BUNDLE_GEMFILE=gemfiles/Gemfile-sidekiq7 bundle exec bin/test
|
128
|
+
```
|
129
|
+
|
130
|
+
## Contributing
|
131
|
+
Contribution directions go here.
|
132
|
+
|
133
|
+
## License
|
134
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'rdoc/task'
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = 'rdoc'
|
11
|
+
rdoc.title = 'SidekiqSmartCache'
|
12
|
+
rdoc.options << '--line-numbers'
|
13
|
+
rdoc.rdoc_files.include('README.md')
|
14
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
|
19
|
+
|
20
|
+
|
21
|
+
|
22
|
+
require 'bundler/gem_tasks'
|
23
|
+
|
24
|
+
require 'rake/testtask'
|
25
|
+
|
26
|
+
Rake::TestTask.new(:test) do |t|
|
27
|
+
t.libs << 'test'
|
28
|
+
t.pattern = 'test/**/*_test.rb'
|
29
|
+
t.verbose = false
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
task default: :test
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module SidekiqSmartCache
|
2
|
+
class Interlock
|
3
|
+
delegate :redis, to: SidekiqSmartCache
|
4
|
+
attr_accessor :cache_tag, :job_interlock_timeout
|
5
|
+
|
6
|
+
def initialize(cache_tag, job_interlock_timeout=nil)
|
7
|
+
@cache_tag = cache_tag
|
8
|
+
@job_interlock_timeout = job_interlock_timeout
|
9
|
+
end
|
10
|
+
|
11
|
+
def key
|
12
|
+
cache_tag + '/in-progress'
|
13
|
+
end
|
14
|
+
|
15
|
+
def working?
|
16
|
+
redis.get(key)
|
17
|
+
end
|
18
|
+
|
19
|
+
def lock_job?
|
20
|
+
redis.set(key, 'winner!', nx: true) && redis.expire(key, job_interlock_timeout)
|
21
|
+
end
|
22
|
+
|
23
|
+
def clear
|
24
|
+
redis.del(key)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
module SidekiqSmartCache::Model
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
module ClassMethods
|
6
|
+
# Takes a class or instance action, makes it operate lazy-loaded through a cache, and adds a couple of related actions to the class.
|
7
|
+
#
|
8
|
+
# === Example
|
9
|
+
#
|
10
|
+
# A User class has a active_user_count class method that is expensive to calculate
|
11
|
+
#
|
12
|
+
# Declaring <tt>make_class_action_cacheable :active_user_count</tt> will add:
|
13
|
+
#
|
14
|
+
# * <tt>User.active_user_count_without_caching</tt> - always performs the full calculation synchronously, doesn't touch the cache.
|
15
|
+
# * <tt>User.refresh_active_user_count</tt> - always performs the full calculation synchronously, populating the cache with the new value.
|
16
|
+
# * <tt>User.active_user_count</tt> (the original name) - will now fetch from the cache, only recalculating if the cache is absent or stale.
|
17
|
+
# * <tt>User.active_user_count_if_available</tt> will now fetch from the cache but not recalculate, returning nil if the cache is absent or stale.
|
18
|
+
# * <tt>User.active_user_count_cache_tag</tt> - the cache tag used to store calculated results. Probably not useful to clients.
|
19
|
+
# * <tt>User.active_user_count_promise</tt> - returns a promise object
|
20
|
+
#
|
21
|
+
# Call <tt>promise.fetch(5.seconds)</tt> to wait up to five seconds for a new value, returning nil on timeout
|
22
|
+
# Call <tt>promise.fetch!(5.seconds)</tt> to wait up to five seconds for a new value, raising SidekiqSmartCache::TimeoutError on timeout
|
23
|
+
#
|
24
|
+
# Use <tt>make_instance_action_cacheable</tt> for the equivalent set of instance methods.
|
25
|
+
# Your models must respond to <tt>to_param</tt> with a unique string suitable for constructing a cache key.
|
26
|
+
# The class must respond to <tt>find(param)</tt> and return an object that responds to the method.
|
27
|
+
#
|
28
|
+
# === Options
|
29
|
+
# [:cache_tag]
|
30
|
+
# Specifies the cache tag to use.
|
31
|
+
# A default cache tag will include the cache_prefix, so will implicitly flush on each release.
|
32
|
+
#
|
33
|
+
# [:expires_in]
|
34
|
+
# Specifies a period after which a cached result will be invalid. Default one hour.
|
35
|
+
#
|
36
|
+
# [:job_interlock_timeout]
|
37
|
+
# When a new value is needed, prevents new refresh jobs from being dispatched.
|
38
|
+
#
|
39
|
+
# Option examples:
|
40
|
+
# make_action_cacheable :active_user_count, expires_in: 12.hours, job_interlock_timeout: 10.minutes
|
41
|
+
# make_instance_action_cacheable :median_post_length, expires_in: 1.hour, job_interlock_timeout: 1.minute
|
42
|
+
delegate :cache_prefix, to: SidekiqSmartCache
|
43
|
+
|
44
|
+
def make_action_cacheable(name, options = {})
|
45
|
+
cache_tag = options[:cache_tag] || [cache_prefix, self.name, name].join('.')
|
46
|
+
cache_options = options.slice(:expires_in, :job_interlock_timeout)
|
47
|
+
instance_method = options[:instance_method]
|
48
|
+
without_caching_name = "#{name}_without_caching"
|
49
|
+
promise_method_name = "#{name}_promise"
|
50
|
+
|
51
|
+
promise_method = ->(*args) do
|
52
|
+
promise_args = cache_options.merge(
|
53
|
+
method: without_caching_name,
|
54
|
+
args: args
|
55
|
+
)
|
56
|
+
|
57
|
+
if instance_method
|
58
|
+
promise_args[:klass] = self.class.name
|
59
|
+
promise_args[:object_param] = to_param
|
60
|
+
else
|
61
|
+
promise_args[:klass] = self.name
|
62
|
+
end
|
63
|
+
SidekiqSmartCache::Promise.new(**promise_args)
|
64
|
+
end
|
65
|
+
|
66
|
+
if_available_method = ->(*args) do
|
67
|
+
send(promise_method_name, *args).existing_value
|
68
|
+
end
|
69
|
+
|
70
|
+
cache_tag_method = ->(*args) do
|
71
|
+
send(promise_method_name, *args).cache_tag
|
72
|
+
end
|
73
|
+
|
74
|
+
with_caching_method = ->(*args) do
|
75
|
+
send(promise_method_name, *args).fetch(24.hours) # close enough to forever?
|
76
|
+
end
|
77
|
+
|
78
|
+
without_caching_method = ->(*args) do
|
79
|
+
method(name).super_method.call(*args)
|
80
|
+
end
|
81
|
+
|
82
|
+
refresh_method = ->(*args) do
|
83
|
+
send(promise_method_name, *args).perform_now
|
84
|
+
end
|
85
|
+
|
86
|
+
prefix_module = Module.new
|
87
|
+
prefix_module.send(:define_method, "#{name}_if_available", if_available_method)
|
88
|
+
prefix_module.send(:define_method, "#{name}_cache_tag", cache_tag_method)
|
89
|
+
prefix_module.send(:define_method, name, with_caching_method)
|
90
|
+
prefix_module.send(:define_method, without_caching_name, without_caching_method)
|
91
|
+
prefix_module.send(:define_method, promise_method_name, promise_method)
|
92
|
+
prefix_module.send(:define_method, "refresh_#{name}", refresh_method)
|
93
|
+
|
94
|
+
if instance_method
|
95
|
+
prepend prefix_module
|
96
|
+
else
|
97
|
+
singleton_class.prepend prefix_module
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def make_class_action_cacheable(name, options = {})
|
102
|
+
make_action_cacheable(name, options)
|
103
|
+
end
|
104
|
+
|
105
|
+
def make_instance_action_cacheable(name, options = {})
|
106
|
+
make_action_cacheable(name, options.merge(instance_method: true))
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module SidekiqSmartCache
|
2
|
+
class Promise
|
3
|
+
attr_accessor :klass, :object_param, :method, :expires_in, :args, :job_interlock_timeout
|
4
|
+
attr_accessor :timed_out
|
5
|
+
delegate :redis, :log, to: SidekiqSmartCache
|
6
|
+
delegate :working?, to: :interlock
|
7
|
+
delegate :value, to: :result
|
8
|
+
delegate :created_at, to: :result, prefix: true
|
9
|
+
|
10
|
+
def initialize(klass: nil, object: nil, object_param: nil, method:, args: nil,
|
11
|
+
cache_tag: nil, expires_in: 1.hour, job_interlock_timeout: nil)
|
12
|
+
if object
|
13
|
+
@klass = object.class.to_s
|
14
|
+
@object_param = object.to_param
|
15
|
+
elsif klass
|
16
|
+
@klass = klass.to_s
|
17
|
+
@object_param = object_param
|
18
|
+
else
|
19
|
+
raise "Must provide either klass or object"
|
20
|
+
end
|
21
|
+
raise "Must provide method" unless method
|
22
|
+
@method = method.to_s
|
23
|
+
@expires_in = expires_in.to_i
|
24
|
+
@job_interlock_timeout = job_interlock_timeout || @expires_in
|
25
|
+
@args = args
|
26
|
+
@cache_tag = cache_tag
|
27
|
+
end
|
28
|
+
|
29
|
+
def cache_tag
|
30
|
+
@cache_tag ||= begin
|
31
|
+
[
|
32
|
+
klass,
|
33
|
+
(object_param || '.'),
|
34
|
+
method,
|
35
|
+
(Digest::MD5.hexdigest(args.compact.to_json) if args.present?)
|
36
|
+
].compact * '/'
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def interlock
|
41
|
+
@_interlock ||= Interlock.new(cache_tag, job_interlock_timeout)
|
42
|
+
end
|
43
|
+
|
44
|
+
def perform_now
|
45
|
+
Worker.new.perform(klass, object_param, method, args, cache_tag, expires_in)
|
46
|
+
end
|
47
|
+
|
48
|
+
def enqueue_job!
|
49
|
+
Worker.perform_async(klass, object_param, method, args, cache_tag, expires_in)
|
50
|
+
end
|
51
|
+
|
52
|
+
def execute_and_wait!(timeout, stale_on_timeout: false)
|
53
|
+
execute_and_wait(timeout, raise_on_timeout: true, stale_on_timeout: stale_on_timeout)
|
54
|
+
end
|
55
|
+
|
56
|
+
def result
|
57
|
+
Result.load_from(cache_tag)
|
58
|
+
end
|
59
|
+
|
60
|
+
def stale_value_available?
|
61
|
+
!!result&.stale?
|
62
|
+
end
|
63
|
+
|
64
|
+
def existing_value(allow_stale: false)
|
65
|
+
if((existing = result) && (allow_stale || existing.fresh?))
|
66
|
+
existing.value
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def ready_within?(timeout)
|
71
|
+
execute_and_wait(timeout)
|
72
|
+
!timed_out
|
73
|
+
end
|
74
|
+
|
75
|
+
def timed_out?
|
76
|
+
!!timed_out
|
77
|
+
end
|
78
|
+
|
79
|
+
def start
|
80
|
+
# Start a job if no other client has
|
81
|
+
if interlock.lock_job?
|
82
|
+
log('promise enqueuing calculator job')
|
83
|
+
enqueue_job!
|
84
|
+
else
|
85
|
+
log('promise calculator job already working')
|
86
|
+
end
|
87
|
+
self # for chaining
|
88
|
+
end
|
89
|
+
|
90
|
+
def execute_and_wait(timeout, raise_on_timeout: false, stale_on_timeout: false)
|
91
|
+
previous_result = result
|
92
|
+
if previous_result&.fresh?
|
93
|
+
# found a previously fresh message
|
94
|
+
@timed_out = false
|
95
|
+
return previous_result.value
|
96
|
+
else
|
97
|
+
start
|
98
|
+
|
99
|
+
# either a job was already running or we started one, now wait for an answer
|
100
|
+
if redis.wait_for_done_message(cache_tag, timeout.to_i)
|
101
|
+
# ready now, fetch it
|
102
|
+
log('promise calculator job finished')
|
103
|
+
@timed_out = false
|
104
|
+
result.value
|
105
|
+
elsif previous_result && stale_on_timeout
|
106
|
+
log('promise timed out awaiting calculator job, serving stale')
|
107
|
+
previous_result.value
|
108
|
+
else
|
109
|
+
log('promise timed out awaiting calculator job')
|
110
|
+
@timed_out = true
|
111
|
+
raise TimeoutError if raise_on_timeout
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
alias_method :fetch!, :execute_and_wait!
|
117
|
+
alias_method :fetch, :execute_and_wait
|
118
|
+
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
module SidekiqSmartCache
|
2
|
+
BLOCKING_COMMANDS = %i[ brpop ].freeze
|
3
|
+
COMMANDS = BLOCKING_COMMANDS + %i[ get set lpush expire flushdb del ].freeze
|
4
|
+
|
5
|
+
ERROR_TO_CATCH = if defined?(::RedisClient)
|
6
|
+
::RedisClient::CommandError
|
7
|
+
else
|
8
|
+
::Redis::CommandError
|
9
|
+
end
|
10
|
+
|
11
|
+
class Redis
|
12
|
+
def initialize(pool)
|
13
|
+
@pool = pool
|
14
|
+
end
|
15
|
+
|
16
|
+
def job_completion_key(key)
|
17
|
+
key + '/done'
|
18
|
+
end
|
19
|
+
|
20
|
+
def send_done_message(key)
|
21
|
+
lpush(job_completion_key(key), 'done')
|
22
|
+
expire(job_completion_key(key), 1)
|
23
|
+
end
|
24
|
+
|
25
|
+
def wait_for_done_message(key, timeout)
|
26
|
+
return true if defined?(Sidekiq::Testing) && Sidekiq::Testing.inline?
|
27
|
+
|
28
|
+
if brpop(job_completion_key(key), timeout: timeout.to_i)
|
29
|
+
# log_msg("got done message for #{key}")
|
30
|
+
send_done_message(key) # put it back for any other readers
|
31
|
+
true
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def log_msg(msg) # WIP all this
|
36
|
+
Rails.logger.info("#{Time.now.iso8601(3)} #{Thread.current[:name]} redis #{msg}")
|
37
|
+
end
|
38
|
+
|
39
|
+
def method_missing(name, ...)
|
40
|
+
@pool.with do |r|
|
41
|
+
if COMMANDS.include? name
|
42
|
+
retryable = true
|
43
|
+
begin
|
44
|
+
# log_msg("#{name} #{args}")
|
45
|
+
if r.respond_to?(name)
|
46
|
+
# old redis gem implements methods including `brpop` and `flusdb`
|
47
|
+
r.send(name, ...)
|
48
|
+
elsif BLOCKING_COMMANDS.include? name
|
49
|
+
# support redis-client semantics
|
50
|
+
make_blocking_call(r, name, ...)
|
51
|
+
else
|
52
|
+
r.call(name.to_s.upcase, ...)
|
53
|
+
end
|
54
|
+
# stolen from sidekiq - Thanks Mike!
|
55
|
+
# Quietly consume this one and return nil
|
56
|
+
rescue ERROR_TO_CATCH => ex
|
57
|
+
# 2550 Failover can cause the server to become a replica, need
|
58
|
+
# to disconnect and reopen the socket to get back to the primary.
|
59
|
+
if retryable && ex.message =~ /READONLY/
|
60
|
+
r.disconnect!
|
61
|
+
retryable = false
|
62
|
+
retry
|
63
|
+
end
|
64
|
+
raise
|
65
|
+
end
|
66
|
+
else
|
67
|
+
super
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def make_blocking_call(r, name, *args)
|
73
|
+
# The Redis `brpop` implementation seems to allow timeout to be the last argument
|
74
|
+
# or a key in a hash `timeout: 5`, so:
|
75
|
+
# `r.brpop('key1', 'key2', 5)` - wait five seconds for either queue
|
76
|
+
# `r.brpop('key1', 'key2', timeout: 5)` - same
|
77
|
+
# `r.brpop('key1', 'key2')` - wait forever on either queue
|
78
|
+
timeout = if args.last.is_a?(Hash)
|
79
|
+
options = args.pop
|
80
|
+
options[:timeout]
|
81
|
+
else
|
82
|
+
args.pop
|
83
|
+
end
|
84
|
+
|
85
|
+
# With RedisClient, the doc is a little thin, but it looks like we want to start with the timeout
|
86
|
+
# then the verb, then the array of keys
|
87
|
+
# and end with ... a 0?
|
88
|
+
blocking_call_args = [timeout, name.to_s.upcase] + args + [0]
|
89
|
+
r.blocking_call(*blocking_call_args)
|
90
|
+
rescue ::RedisClient::TimeoutError
|
91
|
+
nil # quietly return nil in this case
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
class Result
|
2
|
+
class << self
|
3
|
+
delegate :redis, :allowed_classes, to: SidekiqSmartCache
|
4
|
+
end
|
5
|
+
attr_accessor :value, :valid_until, :created_at, :cache_prefix
|
6
|
+
|
7
|
+
def self.persist(cache_tag, value, expires_in)
|
8
|
+
structure = {
|
9
|
+
value: value,
|
10
|
+
created_at: Time.now,
|
11
|
+
valid_until: Time.now + expires_in,
|
12
|
+
cache_prefix: SidekiqSmartCache.cache_prefix
|
13
|
+
}
|
14
|
+
result_lifetime = 1.month.to_i # ??? maybe a function of expires_in ???
|
15
|
+
redis.set(cache_tag, structure.to_yaml)
|
16
|
+
redis.expire(cache_tag, result_lifetime)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.load_from(cache_tag)
|
20
|
+
raw = redis.get(cache_tag)
|
21
|
+
new(YAML.safe_load(raw, allowed_classes)) if raw
|
22
|
+
end
|
23
|
+
|
24
|
+
def initialize(result)
|
25
|
+
@value = result[:value]
|
26
|
+
@created_at = result[:created_at]
|
27
|
+
@valid_until = result[:valid_until]
|
28
|
+
@cache_prefix = result[:cache_prefix]
|
29
|
+
end
|
30
|
+
|
31
|
+
def fresh?
|
32
|
+
!stale?
|
33
|
+
end
|
34
|
+
|
35
|
+
def stale?
|
36
|
+
(Time.now > valid_until) || (cache_prefix != SidekiqSmartCache.cache_prefix)
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'sidekiq'
|
2
|
+
|
3
|
+
module SidekiqSmartCache
|
4
|
+
class Worker
|
5
|
+
include Sidekiq::Worker
|
6
|
+
delegate :redis, to: SidekiqSmartCache
|
7
|
+
|
8
|
+
def perform(klass, instance_id, method, args, cache_tag, expires_in)
|
9
|
+
all_args = [method]
|
10
|
+
if args.is_a?(Array)
|
11
|
+
all_args += args
|
12
|
+
elsif args
|
13
|
+
all_args << args
|
14
|
+
end
|
15
|
+
subject = Object.const_get(klass)
|
16
|
+
subject = subject.find(instance_id) if instance_id
|
17
|
+
result = subject.send(*all_args)
|
18
|
+
Result.persist(cache_tag, result, expires_in)
|
19
|
+
redis.send_done_message(cache_tag)
|
20
|
+
result
|
21
|
+
ensure
|
22
|
+
# remove the interlock key
|
23
|
+
Interlock.new(cache_tag).clear
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require "sidekiq_smart_cache/worker"
|
2
|
+
require "sidekiq_smart_cache/interlock"
|
3
|
+
require "sidekiq_smart_cache/model"
|
4
|
+
require "sidekiq_smart_cache/promise"
|
5
|
+
require "sidekiq_smart_cache/redis"
|
6
|
+
require "sidekiq_smart_cache/result"
|
7
|
+
|
8
|
+
module SidekiqSmartCache
|
9
|
+
class TimeoutError < StandardError; end
|
10
|
+
|
11
|
+
class << self
|
12
|
+
attr_accessor :cache_prefix, :redis_pool, :logger, :log_level
|
13
|
+
|
14
|
+
def allowed_classes
|
15
|
+
(@allowed_classes || []) + [Time, Symbol]
|
16
|
+
end
|
17
|
+
|
18
|
+
def allowed_classes=(extra_classes)
|
19
|
+
@allowed_classes = extra_classes
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.log(message)
|
24
|
+
logger.send((log_level || :info), message) if logger
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.redis
|
28
|
+
@redis ||= Redis.new(redis_pool)
|
29
|
+
end
|
30
|
+
end
|
metadata
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sidekiq_smart_cache
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Bill Kirtley
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-06-16 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: sidekiq
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: sqlite3
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
description: Cache objects in redis, generated with thundering herd prevention via
|
42
|
+
sidekiq.
|
43
|
+
email:
|
44
|
+
- bill.kirtley@gmail.com
|
45
|
+
executables: []
|
46
|
+
extensions: []
|
47
|
+
extra_rdoc_files: []
|
48
|
+
files:
|
49
|
+
- MIT-LICENSE
|
50
|
+
- README.md
|
51
|
+
- Rakefile
|
52
|
+
- lib/sidekiq_smart_cache.rb
|
53
|
+
- lib/sidekiq_smart_cache/interlock.rb
|
54
|
+
- lib/sidekiq_smart_cache/model.rb
|
55
|
+
- lib/sidekiq_smart_cache/promise.rb
|
56
|
+
- lib/sidekiq_smart_cache/redis.rb
|
57
|
+
- lib/sidekiq_smart_cache/result.rb
|
58
|
+
- lib/sidekiq_smart_cache/version.rb
|
59
|
+
- lib/sidekiq_smart_cache/worker.rb
|
60
|
+
- lib/tasks/sidekiq_smart_cache_tasks.rake
|
61
|
+
homepage: https://github.com/cluesque/sidekiq_smart_cache
|
62
|
+
licenses:
|
63
|
+
- MIT
|
64
|
+
metadata: {}
|
65
|
+
post_install_message:
|
66
|
+
rdoc_options: []
|
67
|
+
require_paths:
|
68
|
+
- lib
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '0'
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
75
|
+
requirements:
|
76
|
+
- - ">="
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
version: '0'
|
79
|
+
requirements: []
|
80
|
+
rubygems_version: 3.3.3
|
81
|
+
signing_key:
|
82
|
+
specification_version: 4
|
83
|
+
summary: Cache objects in redis, generated with thundering herd prevention via sidekiq.
|
84
|
+
test_files: []
|