lock_and_cache 2.1.1 → 2.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG +8 -0
- data/README.md +73 -37
- data/lib/lock_and_cache/action.rb +6 -3
- data/lib/lock_and_cache/version.rb +1 -1
- data/lib/lock_and_cache.rb +31 -13
- data/spec/lock_and_cache_spec.rb +20 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b4e54a34f87ae8639c3bca086131b48998817736
|
4
|
+
data.tar.gz: 0063344db4cbe9d2cb01b2e192908810b2277822
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f2fca5d12d2e52b3c3616d4ed529b994299e30c20d203f2084f31220930842264c3bbdcbbac5c8d8caba98ce086ca3d9092b21e03d1bf39d91a04511fd49bb69
|
7
|
+
data.tar.gz: a22749c51adebc1e425a498ee3e4695bafb3512f3b5685d039ab2e04f3312957525f009c906060f172c6f225ff103017adeaf903af14231e2bad8ffae310f598
|
data/CHANGELOG
CHANGED
@@ -1,3 +1,11 @@
|
|
1
|
+
2.2.0 / 2015-11-15
|
2
|
+
|
3
|
+
* Enhancements
|
4
|
+
|
5
|
+
* Increase default heartbeat expires to 32 seconds from 2 (which was too strict IMO)
|
6
|
+
* Allow setting heartbeat_expires: globally (LockAndCache.heartbeat_expires=) or per call
|
7
|
+
* Provide LockAndCache.locked?()
|
8
|
+
|
1
9
|
2.1.1 / 2015-10-26
|
2
10
|
|
3
11
|
* Bug fixes
|
data/README.md
CHANGED
@@ -9,11 +9,27 @@
|
|
9
9
|
|
10
10
|
Lock and cache using redis!
|
11
11
|
|
12
|
+
Most caching libraries don't do locking, meaning that >1 process can be calculating a cached value at the same time. Since you presumably cache things because they cost CPU, database reads, or money, doesn't it make sense to lock while caching?
|
13
|
+
|
14
|
+
## Quickstart
|
15
|
+
|
16
|
+
```ruby
|
17
|
+
LockAndCache.storage = Redis.new
|
18
|
+
|
19
|
+
LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10, nil_expires: 1) do
|
20
|
+
# get yer stock quote
|
21
|
+
# if 50 processes call this at the same time, only 1 will call the stock quote service
|
22
|
+
# the other 49 will wait on the lock, then get the cached value
|
23
|
+
# the value will expire in 10 seconds
|
24
|
+
# but if the value you get back is nil, that will expire after 1 second
|
25
|
+
end
|
26
|
+
```
|
27
|
+
|
12
28
|
## Sponsor
|
13
29
|
|
14
|
-
<p><a href="http://faraday.io"><img src="
|
30
|
+
<p><a href="http://faraday.io"><img src="http://cdn2.hubspot.net/hubfs/515497/img/logo.svg" alt="Faraday logo"/></a></p>
|
15
31
|
|
16
|
-
We use [`lock_and_cache`](https://
|
32
|
+
We use [`lock_and_cache`](https://github.com/seamusabshere/lock_and_cache) for [data-driven marketing at Faraday](http://faraday.io).
|
17
33
|
|
18
34
|
## TOC
|
19
35
|
|
@@ -23,6 +39,7 @@ We use [`lock_and_cache`](https://rubygems.org/gems/lock_and_cache) for [big dat
|
|
23
39
|
|
24
40
|
- [Theory](#theory)
|
25
41
|
- [Practice](#practice)
|
42
|
+
- [Setup](#setup)
|
26
43
|
- [Locking](#locking)
|
27
44
|
- [Caching](#caching)
|
28
45
|
- [Standalone mode](#standalone-mode)
|
@@ -44,22 +61,18 @@ We use [`lock_and_cache`](https://rubygems.org/gems/lock_and_cache) for [big dat
|
|
44
61
|
|
45
62
|
`lock_and_cache`...
|
46
63
|
|
47
|
-
1. returns cached value if
|
48
|
-
2. acquires a lock
|
49
|
-
3. returns cached value
|
50
|
-
4. calculates and caches the value
|
51
|
-
5. releases the lock
|
52
|
-
6. returns the value
|
64
|
+
1. <span style="color: red;">returns cached value</span> (if exists)
|
65
|
+
2. <span style="color: green;">acquires a lock</span>
|
66
|
+
3. <span style="color: red;">returns cached value</span> (just in case it was calculated while we were waiting for a lock)
|
67
|
+
4. <span style="color: red;">calculates and caches the value</span>
|
68
|
+
5. <span style="color: green;">releases the lock</span>
|
69
|
+
6. <span style="color: red;">returns the value</span>
|
53
70
|
|
54
|
-
As you can see, most caching libraries only take care of (1) and (4).
|
71
|
+
As you can see, most caching libraries only take care of (1) and (4) (well, and (5) of course).
|
55
72
|
|
56
73
|
## Practice
|
57
74
|
|
58
|
-
###
|
59
|
-
|
60
|
-
Based on [antirez's Redlock algorithm](http://redis.io/topics/distlock).
|
61
|
-
|
62
|
-
Above and beyond Redlock, a 2-second heartbeat is used that will clear the lock if a process is killed. This is implemented using lock extensions.
|
75
|
+
### Setup
|
63
76
|
|
64
77
|
```ruby
|
65
78
|
LockAndCache.storage = Redis.new
|
@@ -67,79 +80,102 @@ LockAndCache.storage = Redis.new
|
|
67
80
|
|
68
81
|
It will use this redis for both locking and storing cached values.
|
69
82
|
|
83
|
+
### Locking
|
84
|
+
|
85
|
+
Based on [antirez's Redlock algorithm](http://redis.io/topics/distlock).
|
86
|
+
|
87
|
+
Above and beyond Redlock, a 32-second heartbeat is used that will clear the lock if a process is killed. This is implemented using lock extensions.
|
88
|
+
|
70
89
|
### Caching
|
71
90
|
|
72
|
-
|
91
|
+
This gem is a simplified, improved version of https://github.com/seamusabshere/cache_method. In that library, you could only cache a method call.
|
73
92
|
|
74
|
-
|
93
|
+
In this library, you have two options: providing the whole cache key every time (standalone) or letting the library pull information about its context.
|
75
94
|
|
76
95
|
```ruby
|
77
|
-
|
78
|
-
|
96
|
+
# standalone example
|
97
|
+
LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10) do
|
98
|
+
# ...
|
99
|
+
end
|
100
|
+
|
101
|
+
# context example
|
102
|
+
def stock_price(date)
|
103
|
+
lock_and_cache(date, expires: 10) do
|
104
|
+
# ...
|
105
|
+
end
|
106
|
+
end
|
107
|
+
def lock_and_cache_key
|
108
|
+
company
|
79
109
|
end
|
80
110
|
```
|
81
111
|
|
82
|
-
|
112
|
+
#### Standalone mode
|
83
113
|
|
84
114
|
```ruby
|
85
|
-
LockAndCache.lock_and_cache(
|
115
|
+
LockAndCache.lock_and_cache(:stock_price, company: 'MSFT', date: '2015-05-05') do
|
86
116
|
# get yer stock quote
|
87
117
|
end
|
88
118
|
```
|
89
119
|
|
90
|
-
|
120
|
+
You probably want an expiry
|
91
121
|
|
92
122
|
```ruby
|
93
|
-
LockAndCache.lock_and_cache(
|
123
|
+
LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10) do
|
94
124
|
# get yer stock quote
|
95
125
|
end
|
96
126
|
```
|
97
127
|
|
98
128
|
Note how we separated options (`{expires: 10}`) from a hash that is part of the cache key (`{company: 'MSFT', date: '2015-05-05'}`).
|
99
129
|
|
100
|
-
|
130
|
+
One other crazy thing: `nil_expires` - for when you want to check more often if the external stock price service returned nil
|
131
|
+
|
132
|
+
```ruby
|
133
|
+
LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10, nil_expires: 1) do
|
134
|
+
# get yer stock quote
|
135
|
+
end
|
136
|
+
```
|
137
|
+
|
138
|
+
Clear it with
|
101
139
|
|
102
140
|
```ruby
|
103
|
-
LockAndCache.
|
141
|
+
LockAndCache.clear :stock_price, company: 'MSFT', date: '2015-05-05'
|
104
142
|
```
|
105
143
|
|
106
|
-
|
144
|
+
Check locks with
|
107
145
|
|
108
146
|
```ruby
|
109
|
-
LockAndCache.
|
110
|
-
# get yer stock quote
|
111
|
-
end
|
147
|
+
LockAndCache.locked? :stock_price, company: 'MSFT', date: '2015-05-05'
|
112
148
|
```
|
113
149
|
|
114
150
|
#### Context mode
|
115
151
|
|
116
152
|
"Context mode" simply adds the class name, method name, and context key (the results of `#id` or `#lock_and_cache_key`) of the caller to the cache key.
|
117
153
|
|
118
|
-
(This gem evolved from https://github.com/seamusabshere/cache_method, where you always cached a method call...)
|
119
|
-
|
120
154
|
```ruby
|
121
155
|
class Stock
|
122
156
|
include LockAndCache
|
123
157
|
|
124
|
-
def initialize(
|
158
|
+
def initialize(company)
|
125
159
|
[...]
|
126
160
|
end
|
127
161
|
|
128
|
-
def
|
129
|
-
lock_and_cache(date, expires: 10) do
|
130
|
-
#
|
162
|
+
def stock_price(date)
|
163
|
+
lock_and_cache(date, expires: 10) do
|
164
|
+
# the cache key will be StockQuote (the class) + get (the method name) + id (the instance identifier) + date (the arg you specified)
|
131
165
|
end
|
132
166
|
end
|
133
167
|
|
134
168
|
def lock_and_cache_key # <---------- if you don't define this, it will try to call #id
|
135
|
-
|
169
|
+
company
|
136
170
|
end
|
137
171
|
end
|
138
172
|
```
|
139
173
|
|
140
|
-
The key will be
|
174
|
+
The cache key will be StockQuote (the class) + get (the method name) + id (the instance identifier) + date (the arg you specified).
|
175
|
+
|
176
|
+
In other words, it auto-detects the class, method, context key ... and you add other args if you want.
|
141
177
|
|
142
|
-
|
178
|
+
Clear it with
|
143
179
|
|
144
180
|
```ruby
|
145
181
|
blog.lock_and_cache_clear(:get, date)
|
@@ -35,6 +35,9 @@ module LockAndCache
|
|
35
35
|
def perform
|
36
36
|
debug = (ENV['LOCK_AND_CACHE_DEBUG'] == 'true')
|
37
37
|
max_lock_wait = options.fetch 'max_lock_wait', LockAndCache.max_lock_wait
|
38
|
+
heartbeat_expires = options.fetch('heartbeat_expires', LockAndCache.heartbeat_expires).to_f.ceil
|
39
|
+
raise "heartbeat_expires must be >= 2 seconds" unless heartbeat_expires >= 2
|
40
|
+
heartbeat_frequency = (heartbeat_expires / 2).ceil
|
38
41
|
Thread.exclusive { $stderr.puts "[lock_and_cache] A1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
|
39
42
|
if storage.exists(digest) and (existing = storage.get(digest)).is_a?(String)
|
40
43
|
return ::Marshal.load(existing)
|
@@ -45,7 +48,7 @@ module LockAndCache
|
|
45
48
|
lock_info = nil
|
46
49
|
begin
|
47
50
|
Timeout.timeout(max_lock_wait, TimeoutWaitingForLock) do
|
48
|
-
until lock_info = lock_manager.lock(lock_digest,
|
51
|
+
until lock_info = lock_manager.lock(lock_digest, heartbeat_expires*1000)
|
49
52
|
Thread.exclusive { $stderr.puts "[lock_and_cache] C1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
|
50
53
|
sleep rand
|
51
54
|
end
|
@@ -63,10 +66,10 @@ module LockAndCache
|
|
63
66
|
loop do
|
64
67
|
Thread.exclusive { $stderr.puts "[lock_and_cache] heartbeat1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
|
65
68
|
break if done
|
66
|
-
sleep
|
69
|
+
sleep heartbeat_frequency
|
67
70
|
break if done
|
68
71
|
Thread.exclusive { $stderr.puts "[lock_and_cache] heartbeat2 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
|
69
|
-
lock_manager.lock lock_digest,
|
72
|
+
lock_manager.lock lock_digest, heartbeat_expires*1000, extend: lock_info
|
70
73
|
end
|
71
74
|
end
|
72
75
|
retval = blk.call
|
data/lib/lock_and_cache.rb
CHANGED
@@ -10,17 +10,13 @@ require_relative 'lock_and_cache/version'
|
|
10
10
|
require_relative 'lock_and_cache/action'
|
11
11
|
require_relative 'lock_and_cache/key'
|
12
12
|
|
13
|
-
# Lock and cache
|
13
|
+
# Lock and cache using redis!
|
14
14
|
#
|
15
|
-
#
|
15
|
+
# Most caching libraries don't do locking, meaning that >1 process can be calculating a cached value at the same time. Since you presumably cache things because they cost CPU, database reads, or money, doesn't it make sense to lock while caching?
|
16
16
|
module LockAndCache
|
17
17
|
DEFAULT_MAX_LOCK_WAIT = 60 * 60 * 24 # 1 day in seconds
|
18
18
|
|
19
|
-
#
|
20
|
-
LOCK_HEARTBEAT_EXPIRES = 2
|
21
|
-
|
22
|
-
# @private
|
23
|
-
LOCK_HEARTBEAT_PERIOD = 1
|
19
|
+
DEFAULT_HEARTBEAT_EXPIRES = 32 # 32 seconds
|
24
20
|
|
25
21
|
class TimeoutWaitingForLock < StandardError; end
|
26
22
|
|
@@ -51,7 +47,7 @@ module LockAndCache
|
|
51
47
|
#
|
52
48
|
# @note Standalone mode. See also "context mode," where you mix LockAndCache into a class and call it from within its methods.
|
53
49
|
#
|
54
|
-
# @note A single hash arg is treated as a
|
50
|
+
# @note A single hash arg is treated as a cache key, e.g. `LockAndCache.lock_and_cache(foo: :bar, expires: 100)` will be treated as a cache key of `foo: :bar, expires: 100` (which is probably wrong!!!). Try `LockAndCache.lock_and_cache({ foo: :bar }, expires: 100)` instead. This is the opposite of context mode.
|
55
51
|
def LockAndCache.lock_and_cache(*key_parts_and_options, &blk)
|
56
52
|
options = (key_parts_and_options.last.is_a?(Hash) && key_parts_and_options.length > 1) ? key_parts_and_options.pop : {}
|
57
53
|
raise "need a cache key" unless key_parts_and_options.length > 0
|
@@ -68,6 +64,14 @@ module LockAndCache
|
|
68
64
|
key.clear
|
69
65
|
end
|
70
66
|
|
67
|
+
# Check if a key is locked
|
68
|
+
#
|
69
|
+
# @note Standalone mode. See also "context mode," where you mix LockAndCache into a class and call it from within its methods.
|
70
|
+
def LockAndCache.locked?(*key_parts)
|
71
|
+
key = LockAndCache::Key.new key_parts
|
72
|
+
key.locked?
|
73
|
+
end
|
74
|
+
|
71
75
|
# @param seconds [Numeric] Maximum wait time to get a lock
|
72
76
|
#
|
73
77
|
# @note Can be overridden by putting `max_lock_wait:` in your call to `#lock_and_cache`
|
@@ -80,6 +84,20 @@ module LockAndCache
|
|
80
84
|
@max_lock_wait || DEFAULT_MAX_LOCK_WAIT
|
81
85
|
end
|
82
86
|
|
87
|
+
# @param seconds [Numeric] How often a process has to heartbeat in order to keep a lock
|
88
|
+
#
|
89
|
+
# @note Can be overridden by putting `heartbeat_expires:` in your call to `#lock_and_cache`
|
90
|
+
def LockAndCache.heartbeat_expires=(seconds)
|
91
|
+
memo = seconds.to_f
|
92
|
+
raise "heartbeat_expires must be greater than 2 seconds" unless memo >= 2
|
93
|
+
@heartbeat_expires = memo
|
94
|
+
end
|
95
|
+
|
96
|
+
# @private
|
97
|
+
def LockAndCache.heartbeat_expires
|
98
|
+
@heartbeat_expires || DEFAULT_HEARTBEAT_EXPIRES
|
99
|
+
end
|
100
|
+
|
83
101
|
# @private
|
84
102
|
def LockAndCache.lock_manager
|
85
103
|
@lock_manager
|
@@ -87,7 +105,7 @@ module LockAndCache
|
|
87
105
|
|
88
106
|
# Check if a method is locked on an object.
|
89
107
|
#
|
90
|
-
# @note Subject mode - this is expected to be called on an object
|
108
|
+
# @note Subject mode - this is expected to be called on an object whose class has LockAndCache mixed in. See also standalone mode.
|
91
109
|
def lock_and_cache_locked?(method_id, *key_parts)
|
92
110
|
key = LockAndCache::Key.new key_parts, context: self, method_id: method_id
|
93
111
|
key.locked?
|
@@ -95,7 +113,7 @@ module LockAndCache
|
|
95
113
|
|
96
114
|
# Clear a lock and cache given exactly the method and exactly the same arguments
|
97
115
|
#
|
98
|
-
# @note Subject mode - this is expected to be called on an object
|
116
|
+
# @note Subject mode - this is expected to be called on an object whose class has LockAndCache mixed in. See also standalone mode.
|
99
117
|
def lock_and_cache_clear(method_id, *key_parts)
|
100
118
|
key = LockAndCache::Key.new key_parts, context: self, method_id: method_id
|
101
119
|
key.clear
|
@@ -103,15 +121,15 @@ module LockAndCache
|
|
103
121
|
|
104
122
|
# Lock and cache a method given key parts.
|
105
123
|
#
|
106
|
-
#
|
124
|
+
# The cache key will automatically include the class name of the object calling it (the context!) and the name of the method it is called from.
|
107
125
|
#
|
108
126
|
# @param key_parts_and_options [*] Parts that you want to include in the lock and cache key. If the last element is a Hash, it will be treated as options.
|
109
127
|
#
|
110
128
|
# @return The cached value (possibly newly calculated).
|
111
129
|
#
|
112
|
-
# @note Subject mode - this is expected to be called on an object
|
130
|
+
# @note Subject mode - this is expected to be called on an object whose class has LockAndCache mixed in. See also standalone mode.
|
113
131
|
#
|
114
|
-
# @note A single hash arg is treated as an options hash. `lock_and_cache(expires: 100)` will be treated as options `expires: 100`. This is the opposite of standalone mode
|
132
|
+
# @note A single hash arg is treated as an options hash, e.g. `lock_and_cache(expires: 100)` will be treated as options `expires: 100`. This is the opposite of standalone mode.
|
115
133
|
def lock_and_cache(*key_parts_and_options, &blk)
|
116
134
|
options = key_parts_and_options.last.is_a?(Hash) ? key_parts_and_options.pop : {}
|
117
135
|
key = LockAndCache::Key.new key_parts_and_options, context: self, caller: caller
|
data/spec/lock_and_cache_spec.rb
CHANGED
@@ -95,7 +95,7 @@ class Sleeper
|
|
95
95
|
end
|
96
96
|
|
97
97
|
def poke
|
98
|
-
lock_and_cache do
|
98
|
+
lock_and_cache heartbeat_expires: 2 do
|
99
99
|
sleep
|
100
100
|
end
|
101
101
|
end
|
@@ -295,6 +295,16 @@ describe LockAndCache do
|
|
295
295
|
end.to raise_error(/need/)
|
296
296
|
end
|
297
297
|
|
298
|
+
it 'allows checking locks' do
|
299
|
+
expect(LockAndCache.locked?(:sleeper)).to be_falsey
|
300
|
+
t = Thread.new do
|
301
|
+
LockAndCache.lock_and_cache(:sleeper) { sleep 1 }
|
302
|
+
end
|
303
|
+
sleep 0.2
|
304
|
+
expect(LockAndCache.locked?(:sleeper)).to be_truthy
|
305
|
+
t.join
|
306
|
+
end
|
307
|
+
|
298
308
|
it 'allows clearing' do
|
299
309
|
count = 0
|
300
310
|
expect(LockAndCache.lock_and_cache('hello') { count += 1 }).to eq(1)
|
@@ -304,6 +314,15 @@ describe LockAndCache do
|
|
304
314
|
expect(count).to eq(2)
|
305
315
|
end
|
306
316
|
|
317
|
+
it 'allows clearing (complex keys)' do
|
318
|
+
count = 0
|
319
|
+
expect(LockAndCache.lock_and_cache('hello', {world: 1}, expires: 100) { count += 1 }).to eq(1)
|
320
|
+
expect(count).to eq(1)
|
321
|
+
LockAndCache.clear('hello', world: 1)
|
322
|
+
expect(LockAndCache.lock_and_cache('hello', {world: 1}, expires: 100) { count += 1 }).to eq(2)
|
323
|
+
expect(count).to eq(2)
|
324
|
+
end
|
325
|
+
|
307
326
|
it 'allows multi-part keys' do
|
308
327
|
count = 0
|
309
328
|
expect(LockAndCache.lock_and_cache(['hello', 1, { target: 'world' }]) { count += 1 }).to eq(1)
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: lock_and_cache
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Seamus Abshere
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2015-
|
11
|
+
date: 2015-11-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|