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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 19f9d5d8d273859b3802392c2c716bf2a60b897f
4
- data.tar.gz: 45727c9b74dd4fb11b6e8e6fa9dd2655ae01f231
3
+ metadata.gz: b4e54a34f87ae8639c3bca086131b48998817736
4
+ data.tar.gz: 0063344db4cbe9d2cb01b2e192908810b2277822
5
5
  SHA512:
6
- metadata.gz: f27d00caceafde847297680ca8e12c81d2d1f006ff93c50a1f711117d75bd0a1a73a6d118caf29a9cafe7adf5084d6ab66843a2af70e57fec2bd4323a228ad15
7
- data.tar.gz: 7b66cc2ed3a4f4da75dfec0b22afb299163e999f5791cb59ecc931883910412d7a34845790d6abf2e9e4654b3e5821d2deb3098df1796e090d5a71364dda8161
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="https://s3.amazonaws.com/photos.angel.co/startups/i/175701-a63ebd1b56a401e905963c64958204d4-medium_jpg.jpg" alt="Faraday logo"/></a></p>
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://rubygems.org/gems/lock_and_cache) for [big data-driven marketing at Faraday](http://faraday.io).
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 found
48
- 2. acquires a lock
49
- 3. returns cached value if found (just in case it was calculated while we were waiting for a lock)
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
- ### Locking
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
- (be sure to set up storage as above)
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
- #### Standalone mode
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
- LockAndCache.lock_and_cache('stock_price') do
78
- # get yer stock quote
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
- But that's probably not very useful without parameters
112
+ #### Standalone mode
83
113
 
84
114
  ```ruby
85
- LockAndCache.lock_and_cache('stock_price', company: 'MSFT', date: '2015-05-05') do
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
- And you probably want an expiry
120
+ You probably want an expiry
91
121
 
92
122
  ```ruby
93
- LockAndCache.lock_and_cache('stock_price', {company: 'MSFT', date: '2015-05-05'}, expires: 10) do
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
- You can clear a cache:
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.lock_and_cache('stock_price', company: 'MSFT', date: '2015-05-05')
141
+ LockAndCache.clear :stock_price, company: 'MSFT', date: '2015-05-05'
104
142
  ```
105
143
 
106
- One other crazy thing: let's say you want to check more often if the external stock price service returned nil
144
+ Check locks with
107
145
 
108
146
  ```ruby
109
- LockAndCache.lock_and_cache('stock_price', {company: 'MSFT', date: '2015-05-05'}, expires: 10, nil_expires: 1) do
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(ticker_symbol)
158
+ def initialize(company)
125
159
  [...]
126
160
  end
127
161
 
128
- def price(date)
129
- lock_and_cache(date, expires: 10) do # <------ see how the cache key depends on the method args?
130
- # do the work
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
- ticker_symbol
169
+ company
136
170
  end
137
171
  end
138
172
  ```
139
173
 
140
- The key will be `{ StockQuote, :get, $id, $date, }`. In other words, it auto-detects the class, method, context key ... and you add other args if you want.
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
- Here's how to clear a cache in context mode:
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, LockAndCache::LOCK_HEARTBEAT_EXPIRES*1000)
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 LockAndCache::LOCK_HEARTBEAT_PERIOD
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, LockAndCache::LOCK_HEARTBEAT_EXPIRES*1000, extend: lock_info
72
+ lock_manager.lock lock_digest, heartbeat_expires*1000, extend: lock_info
70
73
  end
71
74
  end
72
75
  retval = blk.call
@@ -1,3 +1,3 @@
1
1
  module LockAndCache
2
- VERSION = '2.1.1'
2
+ VERSION = '2.2.0'
3
3
  end
@@ -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 methods using redis!
13
+ # Lock and cache using redis!
14
14
  #
15
- # I bet you're caching, but are you locking?
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
- # @private
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 cached key. `LockAndCache.lock_and_cache(foo: :bar, expires: 100)` will be treated as a cache key of `foo: :bar, expires: 100` (which is probably wrong!!!). `LockAndCache.lock_and_cache({foo: :bar}, expires: 100)` will be treated as a cache key of `foo: :bar` and options `expires: 100`. This is the opposite of context mode and is true because we don't have any context to set the cache key from otherwise.
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 that has LockAndCache mixed in. See also standalone mode.
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 that has LockAndCache mixed in. See also standalone mode.
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
- # This is the defining characteristic of context mode: 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.
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 that has LockAndCache mixed in. See also standalone mode.
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 and true because we want to support people constructing cache keys from the context (context) PLUS an arbitrary hash of stuff.
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
@@ -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.1.1
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-10-26 00:00:00.000000000 Z
11
+ date: 2015-11-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport