lock_and_cache 1.1.0 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 240e0a726e297cc0a7ceb14a90f64ded7a6a2717
4
- data.tar.gz: 2ebadb4c71d8e9044ccceb7e5b9025d3d1c67631
3
+ metadata.gz: 3d82a253311d779887e8cc365b71c27004d894ee
4
+ data.tar.gz: 54764d5ef99783e9476406fd6c89c53ad9ae9e0f
5
5
  SHA512:
6
- metadata.gz: 440b0ab4a18aadec5170d99600467353fb25a3d6f448bbc5e8628845941fa366c810637f526e49e4918be0f58729a85f5094372aec63addfc5ae9162ab13a142
7
- data.tar.gz: 5ee8fd515dcfde1ad4f20c2cc4c17526a548dda99c1fa8b6160b912a5a9486d6dfdafe0b1389777f8362a362e8397090e22611b9234aab65cae4f874e640e56a
6
+ metadata.gz: edc54e74ebf38fb009f8211999881b7bc6e8b0addf193c0db828caf6044e63a178c89dc6802a2c1ff459bd35ff3e44531520d9ce18e37b66fe31d572e8f08aed
7
+ data.tar.gz: 8dc9e372a03506867174c05b52ba9e15067937388549bacc7afc7f70fccfd26b883c1d36159329e605da7dcaea022a6b0521a4d154273772e5474dea170ba184
data/.yardopts CHANGED
@@ -1,2 +1,3 @@
1
1
  --no-private
2
2
  --readme README.md
3
+ --markup-provider redcarpet
data/CHANGELOG CHANGED
@@ -1,3 +1,19 @@
1
+ 2.0.0 / 2015-09-11
2
+
3
+ * Breaking changes
4
+
5
+ * Stricter key digest - differentiates symbols and strings
6
+ * No more lock_expires or lock_spin options
7
+
8
+ * Bug fixes
9
+
10
+ * Allow method names with non-word chars like #foo?
11
+
12
+ * Enhancements
13
+
14
+ * heartbeats so that SIGKILL will effectively clear the lock
15
+ * #lock_and_cache_clear now clears lock too
16
+
1
17
  1.1.0 / 2015-08-07
2
18
 
3
19
  * Breaking changes
data/README.md CHANGED
@@ -4,22 +4,45 @@
4
4
  [![Code Climate](https://codeclimate.com/github/seamusabshere/lock_and_cache/badges/gpa.svg)](https://codeclimate.com/github/seamusabshere/lock_and_cache)
5
5
  [![Dependency Status](https://gemnasium.com/seamusabshere/lock_and_cache.svg)](https://gemnasium.com/seamusabshere/lock_and_cache)
6
6
  [![Gem Version](https://badge.fury.io/rb/lock_and_cache.svg)](http://badge.fury.io/rb/lock_and_cache)
7
- [![security](https://hakiri.io/github/seamusabshere/lock_and_cache/master.svg)](https://hakiri.io/github/seamusabshere/lock_and_cache/master)
7
+ [![Security](https://hakiri.io/github/seamusabshere/lock_and_cache/master.svg)](https://hakiri.io/github/seamusabshere/lock_and_cache/master)
8
8
  [![Inline docs](http://inch-ci.org/github/seamusabshere/lock_and_cache.svg?branch=master)](http://inch-ci.org/github/seamusabshere/lock_and_cache)
9
9
 
10
10
  Lock and cache using redis!
11
11
 
12
- ## Redlock locking
12
+ ## Sponsor
13
+
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>
15
+
16
+ We use [`lock_and_cache`](https://rubygems.org/gems/lock_and_cache) for [big data-driven marketing at Faraday](http://faraday.io).
17
+
18
+ ## Theory
19
+
20
+ `lock_and_cache`...
21
+
22
+ 1. returns cached value if found
23
+ 2. acquires a lock
24
+ 3. returns cached value if found (just in case it was calculated while we were waiting for a lock)
25
+ 4. calculates and caches the value
26
+ 5. releases the lock
27
+ 6. returns the value
28
+
29
+ As you can see, most caching libraries only take care of (1) and (4).
30
+
31
+ ## Practice
32
+
33
+ ### Locking (antirez's Redlock)
13
34
 
14
35
  Based on [antirez's Redlock algorithm](http://redis.io/topics/distlock).
15
36
 
37
+ 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.
38
+
16
39
  ```ruby
17
40
  LockAndCache.storage = Redis.new
18
41
  ```
19
42
 
20
43
  It will use this redis for both locking and storing cached values.
21
44
 
22
- ## Convenient caching
45
+ ### Caching (block inside of a method)
23
46
 
24
47
  (be sure to set up storage as above)
25
48
 
@@ -52,8 +75,6 @@ end
52
75
  ## Tunables
53
76
 
54
77
  * `LockAndCache.storage=[redis]`
55
- * `LockAndCache.lock_expires=[seconds]` default is 3 days
56
- * `LockAndCache.lock_spin=[seconds]` (how long to wait before retrying lock) default is 0.1 seconds
57
78
  * `ENV['LOCK_AND_CACHE_DEBUG']='true'` if you want some debugging output on `$stderr`
58
79
 
59
80
  ## Few dependencies
@@ -61,13 +82,6 @@ end
61
82
  * [activesupport](https://rubygems.org/gems/activesupport) (come on, it's the bomb)
62
83
  * [redis](https://github.com/redis/redis-rb)
63
84
  * [redlock](https://github.com/leandromoreira/redlock-rb)
64
- * [hash_digest](https://github.com/seamusabshere/hash_digest) (which requires [murmurhash3](https://github.com/funny-falcon/murmurhash3-ruby))
65
-
66
- ## Real-world usage
67
-
68
- <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>
69
-
70
- We use [`lock_and_cache`](https://rubygems.org/gems/lock_and_cache) for [big data-driven marketing at Faraday](http://angel.co/faraday).
71
85
 
72
86
  ## Contributing
73
87
 
@@ -1,23 +1,31 @@
1
1
  require 'lock_and_cache/version'
2
2
  require 'timeout'
3
+ require 'digest/sha1'
4
+ require 'base64'
3
5
  require 'redis'
4
6
  require 'redlock'
5
- require 'hash_digest'
6
7
  require 'active_support'
7
8
  require 'active_support/core_ext'
8
9
 
10
+ # Lock and cache methods using redis!
11
+ #
12
+ # I bet you're caching, but are you locking?
9
13
  module LockAndCache
10
- DEFAULT_LOCK_EXPIRES = 60 * 60 * 24 * 1 * 1000 # 1 day in milliseconds
11
- DEFAULT_LOCK_SPIN = 0.1
12
14
  DEFAULT_MAX_LOCK_WAIT = 60 * 60 * 24 # 1 day in seconds
13
15
 
16
+ # @private
17
+ LOCK_HEARTBEAT_EXPIRES = 2
18
+
19
+ # @private
20
+ LOCK_HEARTBEAT_PERIOD = 1
21
+
14
22
  class TimeoutWaitingForLock < StandardError; end
15
23
 
16
24
  # @param redis_connection [Redis] A redis connection to be used for lock and cached value storage
17
25
  def LockAndCache.storage=(redis_connection)
18
26
  raise "only redis for now" unless redis_connection.class.to_s == 'Redis'
19
27
  @storage = redis_connection
20
- @lock_manager = Redlock::Client.new [redis_connection]
28
+ @lock_manager = Redlock::Client.new [redis_connection], retry_count: 1
21
29
  end
22
30
 
23
31
  # @return [Redis] The redis connection used for lock and cached value storage
@@ -32,31 +40,6 @@ module LockAndCache
32
40
  storage.flushdb
33
41
  end
34
42
 
35
- # @param seconds [Numeric] Lock expiry in seconds.
36
- #
37
- # @note Can be overridden by putting `expires:` in your call to `#lock_and_cache`
38
- def LockAndCache.lock_expires=(seconds)
39
- @lock_expires = seconds.to_f * 1000
40
- end
41
-
42
- # @return [Numeric] Lock expiry in milliseconds.
43
- # @private
44
- def LockAndCache.lock_expires
45
- @lock_expires || DEFAULT_LOCK_EXPIRES
46
- end
47
-
48
- # @param seconds [Numeric] How long to wait before trying a lock again, in seconds
49
- #
50
- # @note Can be overridden by putting `lock_spin:` in your call to `#lock_and_cache`
51
- def LockAndCache.lock_spin=(seconds)
52
- @lock_spin = seconds.to_f
53
- end
54
-
55
- # @private
56
- def LockAndCache.lock_spin
57
- @lock_spin || DEFAULT_LOCK_SPIN
58
- end
59
-
60
43
  # @param seconds [Numeric] Maximum wait time to get a lock
61
44
  #
62
45
  # @note Can be overridden by putting `max_lock_wait:` in your call to `#lock_and_cache`
@@ -81,18 +64,28 @@ module LockAndCache
81
64
 
82
65
  def initialize(obj, method_id, parts)
83
66
  @obj = obj
84
- @method_id = method_id
67
+ @method_id = method_id.to_sym
85
68
  @_parts = parts
86
69
  end
87
70
 
71
+ # A (non-cryptographic) digest of the key parts for use as the cache key
88
72
  def digest
89
- @digest ||= ::HashDigest.digest3([obj_class_name, method_id] + parts)
73
+ @digest ||= ::Digest::SHA1.hexdigest ::Marshal.dump(key)
90
74
  end
91
75
 
92
- def debug
93
- @debug ||= [obj_class_name, method_id] + parts
76
+ # A (non-cryptographic) digest of the key parts for use as the lock key
77
+ def lock_digest
78
+ @lock_digest ||= 'lock/' + digest
94
79
  end
95
80
 
81
+ # A human-readable representation of the key parts
82
+ def key
83
+ @key ||= [obj_class_name, method_id, parts]
84
+ end
85
+
86
+ alias debug key
87
+
88
+ # An array of the parts we use for the key
96
89
  def parts
97
90
  @parts ||= @_parts.map do |v|
98
91
  case v
@@ -104,21 +97,26 @@ module LockAndCache
104
97
  end
105
98
  end
106
99
 
100
+ # An object (or its class's) name
107
101
  def obj_class_name
108
102
  @obj_class_name ||= (obj.class == ::Class) ? obj.name : obj.class.name
109
103
  end
110
104
 
111
105
  end
112
106
 
113
- # Clear a cache given exactly the method and exactly the same arguments
114
- #
115
- # @note Does not unlock.
107
+ def lock_and_cache_locked?(method_id, *key_parts)
108
+ debug = (ENV['LOCK_AND_CACHE_DEBUG'] == 'true')
109
+ key = LockAndCache::Key.new self, method_id, key_parts
110
+ LockAndCache.storage.exists key.lock_digest
111
+ end
112
+
113
+ # Clear a lock and cache given exactly the method and exactly the same arguments
116
114
  def lock_and_cache_clear(method_id, *key_parts)
117
115
  debug = (ENV['LOCK_AND_CACHE_DEBUG'] == 'true')
118
116
  key = LockAndCache::Key.new self, method_id, key_parts
119
- Thread.exclusive { $stderr.puts "[lock_and_cache] clear #{key.debug}" } if debug
120
- digest = key.digest
121
- LockAndCache.storage.del digest
117
+ Thread.exclusive { $stderr.puts "[lock_and_cache] clear #{key.debug} #{Base64.encode64(key.digest).strip} #{Digest::SHA1.hexdigest key.digest}" } if debug
118
+ LockAndCache.storage.del key.digest
119
+ LockAndCache.storage.del key.lock_digest
122
120
  end
123
121
 
124
122
  # Lock and cache a method given key parts.
@@ -129,43 +127,58 @@ module LockAndCache
129
127
  def lock_and_cache(*key_parts)
130
128
  raise "need a block" unless block_given?
131
129
  debug = (ENV['LOCK_AND_CACHE_DEBUG'] == 'true')
132
- caller[0] =~ /in `(\w+)'/
133
- method_id = $1 or raise "couldn't get method_id from #{kaller[0]}"
130
+ caller[0] =~ /in `([^']+)'/
131
+ method_id = $1 or raise "couldn't get method_id from #{caller[0]}"
134
132
  options = key_parts.last.is_a?(Hash) ? key_parts.pop.stringify_keys : {}
135
133
  expires = options['expires']
136
- lock_expires = options.fetch 'lock_expires', LockAndCache.lock_expires
137
- lock_spin = options.fetch 'lock_spin', LockAndCache.lock_spin
138
134
  max_lock_wait = options.fetch 'max_lock_wait', LockAndCache.max_lock_wait
139
135
  key = LockAndCache::Key.new self, method_id, key_parts
140
136
  digest = key.digest
141
- storage = LockAndCache.storage
142
- Thread.exclusive { $stderr.puts "[lock_and_cache] A1 #{key.debug}" } if debug
137
+ storage = LockAndCache.storage or raise("must set LockAndCache.storage=[Redis]")
138
+ Thread.exclusive { $stderr.puts "[lock_and_cache] A1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
143
139
  if storage.exists digest
144
140
  return ::Marshal.load(storage.get(digest))
145
141
  end
146
- Thread.exclusive { $stderr.puts "[lock_and_cache] B1 #{key.debug}" } if debug
142
+ Thread.exclusive { $stderr.puts "[lock_and_cache] B1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
147
143
  retval = nil
148
144
  lock_manager = LockAndCache.lock_manager
149
- lock_digest = 'lock/' + digest
145
+ lock_digest = key.lock_digest
150
146
  lock_info = nil
151
147
  begin
152
148
  Timeout.timeout(max_lock_wait, TimeoutWaitingForLock) do
153
- until lock_info = lock_manager.lock(lock_digest, lock_expires)
154
- Thread.exclusive { $stderr.puts "[lock_and_cache] C1 #{key.debug}" } if debug
155
- sleep lock_spin
149
+ until lock_info = lock_manager.lock(lock_digest, LockAndCache::LOCK_HEARTBEAT_EXPIRES*1000)
150
+ Thread.exclusive { $stderr.puts "[lock_and_cache] C1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
151
+ sleep rand
156
152
  end
157
153
  end
158
- Thread.exclusive { $stderr.puts "[lock_and_cache] D1 #{key.debug}" } if debug
154
+ Thread.exclusive { $stderr.puts "[lock_and_cache] D1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
159
155
  if storage.exists digest
160
- Thread.exclusive { $stderr.puts "[lock_and_cache] E1 #{key.debug}" } if debug
156
+ Thread.exclusive { $stderr.puts "[lock_and_cache] E1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
161
157
  retval = ::Marshal.load storage.get(digest)
162
158
  else
163
- Thread.exclusive { $stderr.puts "[lock_and_cache] F1 #{key.debug}" } if debug
164
- retval = yield
165
- if expires
166
- storage.setex digest, expires, ::Marshal.dump(retval)
167
- else
168
- storage.set digest, ::Marshal.dump(retval)
159
+ Thread.exclusive { $stderr.puts "[lock_and_cache] F1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
160
+ done = false
161
+ begin
162
+ lock_extender = Thread.new do
163
+ loop do
164
+ Thread.exclusive { $stderr.puts "[lock_and_cache] heartbeat1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
165
+ break if done
166
+ sleep LockAndCache::LOCK_HEARTBEAT_PERIOD
167
+ break if done
168
+ Thread.exclusive { $stderr.puts "[lock_and_cache] heartbeat2 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } if debug
169
+ lock_manager.lock lock_digest, LockAndCache::LOCK_HEARTBEAT_EXPIRES*1000, extend: lock_info
170
+ end
171
+ end
172
+ retval = yield
173
+ if expires
174
+ storage.setex digest, expires, ::Marshal.dump(retval)
175
+ else
176
+ storage.set digest, ::Marshal.dump(retval)
177
+ end
178
+ ensure
179
+ done = true
180
+ lock_extender.exit if lock_extender.alive?
181
+ lock_extender.join if lock_extender.status.nil?
169
182
  end
170
183
  end
171
184
  ensure
@@ -1,3 +1,3 @@
1
1
  module LockAndCache
2
- VERSION = '1.1.0'
2
+ VERSION = '2.0.0'
3
3
  end
@@ -19,9 +19,9 @@ Gem::Specification.new do |spec|
19
19
  spec.require_paths = ["lib"]
20
20
 
21
21
  spec.add_runtime_dependency 'activesupport'
22
- spec.add_runtime_dependency 'hash_digest'
23
22
  spec.add_runtime_dependency 'redis'
24
- spec.add_runtime_dependency 'redlock'
23
+ # temporary until https://github.com/leandromoreira/redlock-rb/pull/20 is merged
24
+ spec.add_runtime_dependency 'seamusabshere-redlock'
25
25
 
26
26
  spec.add_development_dependency 'pry'
27
27
  spec.add_development_dependency 'bundler', '~> 1.6'
@@ -29,4 +29,5 @@ Gem::Specification.new do |spec|
29
29
  spec.add_development_dependency 'rspec'
30
30
  spec.add_development_dependency 'thread'
31
31
  spec.add_development_dependency 'yard'
32
+ spec.add_development_dependency 'redcarpet'
32
33
  end
@@ -65,6 +65,24 @@ class Bar
65
65
  end
66
66
  end
67
67
 
68
+ class Sleeper
69
+ include LockAndCache
70
+
71
+ def initialize
72
+ @id = SecureRandom.hex
73
+ end
74
+
75
+ def poke
76
+ lock_and_cache do
77
+ sleep
78
+ end
79
+ end
80
+
81
+ def lock_and_cache_key
82
+ @id
83
+ end
84
+ end
85
+
68
86
  describe LockAndCache do
69
87
  before do
70
88
  LockAndCache.flush
@@ -168,6 +186,59 @@ describe LockAndCache do
168
186
  end
169
187
  end
170
188
 
189
+ it 'unlocks if a process dies' do
190
+ child = nil
191
+ begin
192
+ sleeper = Sleeper.new
193
+ child = fork do
194
+ sleeper.poke
195
+ end
196
+ sleep 0.1
197
+ expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other process has it
198
+ Process.kill 'KILL', child
199
+ expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other (dead) process still has it
200
+ sleep 2
201
+ expect(sleeper.lock_and_cache_locked?(:poke)).to eq(false) # but now it should be cleared because no heartbeat
202
+ ensure
203
+ Process.kill('KILL', child) rescue Errno::ESRCH
204
+ end
205
+ end
206
+
207
+ it "pays attention to heartbeats" do
208
+ child = nil
209
+ begin
210
+ sleeper = Sleeper.new
211
+ child = fork do
212
+ sleeper.poke
213
+ end
214
+ sleep 0.1
215
+ expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other process has it
216
+ sleep 2
217
+ expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other process still has it
218
+ sleep 2
219
+ expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other process still has it
220
+ sleep 2
221
+ expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other process still has it
222
+ ensure
223
+ Process.kill('TERM', child) rescue Errno::ESRCH
224
+ end
225
+ end
226
+
171
227
  end
172
228
 
229
+ describe 'keying' do
230
+ it "doesn't conflate symbol and string args" do
231
+ symbol = LockAndCache::Key.new(Foo.new(:me), :click, a: 1)
232
+ string = LockAndCache::Key.new(Foo.new(:me), :click, 'a' => 1)
233
+ expect(symbol.digest).not_to eq(string.digest)
234
+ end
235
+
236
+ it "cares about order" do
237
+ symbol = LockAndCache::Key.new(Foo.new(:me), :click, {a: 1, b: 2})
238
+ string = LockAndCache::Key.new(Foo.new(:me), :click, {b: 2, a: 1})
239
+ expect(symbol.digest).not_to eq(string.digest)
240
+ end
241
+ end
242
+
243
+
173
244
  end
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: 1.1.0
4
+ version: 2.0.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-08-08 00:00:00.000000000 Z
11
+ date: 2015-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -24,20 +24,6 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
- - !ruby/object:Gem::Dependency
28
- name: hash_digest
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '0'
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: redis
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -53,7 +39,7 @@ dependencies:
53
39
  - !ruby/object:Gem::Version
54
40
  version: '0'
55
41
  - !ruby/object:Gem::Dependency
56
- name: redlock
42
+ name: seamusabshere-redlock
57
43
  requirement: !ruby/object:Gem::Requirement
58
44
  requirements:
59
45
  - - ">="
@@ -150,6 +136,20 @@ dependencies:
150
136
  - - ">="
151
137
  - !ruby/object:Gem::Version
152
138
  version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: redcarpet
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
153
  description: Lock and cache methods, in case things should only be calculated once
154
154
  across processes.
155
155
  email: