lock_and_cache 2.1.1 → 2.2.0
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 +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
|