lock_and_cache 1.1.0 → 2.0.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: 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: