mongo-locking 0.0.1

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.
data/LICENSE ADDED
@@ -0,0 +1 @@
1
+ Still working this out.
data/README.txt ADDED
@@ -0,0 +1,42 @@
1
+ ##
2
+ ## Usages
3
+ ##
4
+ #
5
+ # Order.lockable!
6
+ # Order.lockable! :key => :id
7
+ # Order.lockable! :key => proc { |me| SHA1.hexdigest(me.balls) }
8
+ # Order.lockable! :scope => "OtherClass"
9
+ #
10
+ # OrderItem.locked_by! { |me| me.order }
11
+ # OrderItem.locked_by! :parent => proc { |me| me.order }
12
+ # OrderItem.locked_by! :order
13
+ # OrderItem.locked_by! :parent => :order
14
+ #
15
+ ##
16
+ ## Useful tests
17
+ ##
18
+ #
19
+ # Pn == process N
20
+ # Order.id == 1
21
+ # OrderItem.id == 1, OrderItem.order_id = 1
22
+ #
23
+ # - General race, same object
24
+ #
25
+ # P1: Order.first.lock { debugger } # gets and holds lock
26
+ # P2: Order.first.lock { puts "hi" } # retries acquire, fails
27
+ #
28
+ # - General race, locked root, attempt to lock from child
29
+ #
30
+ # P1: Order.first.lock { debugger } # gets and holds lock
31
+ # P2: OrderItem.first.lock { puts "hi" } # retries acquire, fails
32
+ #
33
+ # - General race, locked root from child, attempt to lock from child
34
+ #
35
+ # P1: OrderItem.first.lock { debugger } # gets and holds lock
36
+ # P2: OrderItem.first.lock { puts "hi" } # retries acquire, fails
37
+ #
38
+ # - Nested lock acquisition
39
+ #
40
+ # P1: Order.first.lock { puts "1"; Order.first.lock { puts "2" } }
41
+ # # should see 1 and 2
42
+ #
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ task :default do
2
+ puts "TBC"
3
+ end
@@ -0,0 +1,95 @@
1
+ ##
2
+ ## Mongo Locking Library
3
+ ##
4
+ #
5
+ # Despite the Locker being placed on the model classes, the dependency graph is
6
+ # actually instance-based, thus the graph can only be resolved during runtime.
7
+ #
8
+ # Locking takes an instance to start with, optionally walks the graph through
9
+ # parent "pointers" (procs whose arbitrary function produces the parent
10
+ # lockable), then does the atomic incr/decr dance with the root lockable's key.
11
+ #
12
+ # TODO: More docs. TBC.
13
+
14
+ require 'mongo'
15
+ require 'active_support/core_ext/module/delegation'
16
+
17
+ module Mongo
18
+ module Locking
19
+ extend self
20
+
21
+ module Exceptions
22
+ class Error < RuntimeError; end
23
+ class ArgumentError < Error; end # bad param
24
+ class InvalidConfig < Error; end # something bogus in config[]
25
+ class CircularLock < Error; end # circular lock reference
26
+ class LockTimeout < Error; end # lock failed, retries unsuccessful
27
+ class LockFailure < Error; end # lock failed, something bad happened
28
+ end
29
+ include Exceptions
30
+
31
+ attr_accessor :logger, :collection
32
+ delegate :debug, :info, :warn, :error, :fatal, :to => :logger, :allow_nil => true
33
+
34
+ def included(klass)
35
+ klass.send(:include, ModelMethods)
36
+ end
37
+
38
+ ##
39
+ ## Configuration
40
+ ##
41
+
42
+ def configure(opts = {})
43
+ opts.each { |k, v| send("#{k}=", v) }
44
+ return self
45
+ end
46
+
47
+ def collection=(new)
48
+ @collection = new
49
+ ensure_indices if @collection.kind_of? Mongo::Collection
50
+ return @collection
51
+ end
52
+
53
+ # Allow for a proc to be initially set as the collection, in case of
54
+ # delayed loading/configuration/whatever. It'll only be materialized
55
+ # when first accessed, which is presumably either when ensure_indices is
56
+ # called imperatively, or more likely upon the first lock call.
57
+ def collection
58
+ if @collection.kind_of? Proc
59
+ @collection = @collection.call
60
+ ensure_indices if @collection.kind_of? Mongo::Collection
61
+ end
62
+ return @collection
63
+ end
64
+
65
+ ##
66
+ ## Document Indices
67
+ ##
68
+ #
69
+ # A Mongoid model would look like:
70
+ # field :scope, :type => String
71
+ # field :key, :type => String
72
+ # field :refcount, :type => Integer, :default => 0
73
+ # field :expire_at, :type => DateTime
74
+ # index [ [ :scope, Mongo::ASCENDING ], [ :key, Mongo::ASCENDING ] ], :unique => true
75
+ # index :refcount
76
+ # index :expire_at
77
+
78
+ LOCK_INDICES = {
79
+ [["scope", Mongo::ASCENDING], ["key", Mongo::ASCENDING]] => { :unique => true, :background => true },
80
+ [["refcount", Mongo::ASCENDING]] => { :unique => false, :background => true },
81
+ [["expire_at", Mongo::ASCENDING]] => { :unique => false, :background => true },
82
+ }
83
+
84
+ def ensure_indices
85
+ LOCK_INDICES.each do |spec, opts|
86
+ @collection.ensure_index(spec, opts)
87
+ end
88
+ end
89
+
90
+ end # Locking
91
+ end # Mongo
92
+
93
+ require 'mongo/locking/locker'
94
+ require 'mongo/locking/model_methods'
95
+
@@ -0,0 +1,274 @@
1
+ # Locker is a container for locking related commands, so we minimally impact the
2
+ # model's namespace.
3
+ #
4
+ # In addition to the Mongo-based, per-process, blocking lock mechanism itself,
5
+ # we also employ a thread-local lock refcount to achieve non-blocking behaviour
6
+ # when nesting lock closures. This is useful for when multiple, isolated code
7
+ # paths are all defensive with locks, but are arbitrarily called within the same
8
+ # thread of execution.
9
+ #
10
+ # We try to limit the number of these objects, so as to minimze extra cost
11
+ # attached to model instance hydration. Thus the Locker is attached to the
12
+ # model class, and maintains refcounts based on the key it's configured with.
13
+
14
+ require 'mongo/locking'
15
+ require 'active_support/core_ext/numeric/time'
16
+ require 'active_support/core_ext/object/blank'
17
+
18
+ module Mongo
19
+ module Locking
20
+
21
+ class Locker
22
+ include Exceptions
23
+
24
+ attr_reader :config
25
+
26
+ DEFAULT_OPTIONS = {
27
+ :max_retries => 5,
28
+ :first_retry_interval => 0.2.seconds,
29
+ :max_retry_interval => 5.seconds,
30
+ :max_lifetime => 10.minutes,
31
+ }
32
+
33
+ def initialize(opts = {})
34
+ @config = DEFAULT_OPTIONS.merge(opts)
35
+ @refcount_key = "mongo_locking_refcounts_#{@config[:class_name]}"
36
+ end
37
+
38
+ def refcounts
39
+ Thread.current[@refcount_key] ||= Hash.new(0)
40
+ end
41
+
42
+ def lock(from)
43
+ lockable = root_for(from)
44
+ locker = lockable.class.locker
45
+
46
+ key = locker.key_for(lockable)
47
+ scope = locker.scope_for(lockable)
48
+
49
+ locker.acquire(scope, key)
50
+
51
+ return lockable
52
+ end
53
+
54
+ def unlock(from)
55
+ lockable = root_for(from)
56
+ locker = lockable.class.locker
57
+
58
+ key = locker.key_for(lockable)
59
+ scope = locker.scope_for(lockable)
60
+
61
+ locker.release(scope, key)
62
+
63
+ return lockable
64
+ end
65
+
66
+ # refcounts[] is about enabling non-blocking behaviour when the
67
+ # process has already acquired the lock (e.g. controller locks
68
+ # Order, calls model save method that also locks Order). In a way,
69
+ # it's like an IdentityMap, mapping instance keys to reference
70
+ # counts (another reason it lives on the class and not the
71
+ # instance).
72
+ #
73
+ # We increment it immediately so that any further (nested) calls
74
+ # won't block, but that means we have to make sure to decrement it
75
+ # on every failure case.
76
+ def acquire(scope, key)
77
+ name = scope + "/" + key
78
+
79
+ refcounts[key] += 1
80
+ if refcounts[key] > 1
81
+ Locking.info "acquire: re-using lock for #{name}##{refcounts[key]}"
82
+ return true
83
+ end
84
+
85
+ target = { :scope => scope, :key => key }
86
+ interval = self.config[:first_retry_interval]
87
+ retries = 0
88
+
89
+ Locking.debug "acquire: attempting lock of #{name}"
90
+
91
+ begin
92
+ a_lock = atomic_inc(target, { :refcount => 1 })
93
+ refcount = a_lock['refcount']
94
+
95
+ # FIXME: If the lock is "expired" then we just inherit the
96
+ # lock and assume the user of the lock is "gone", right? Do
97
+ # we need to do some decr/incr? Is this correct?
98
+ if a_lock['expire_at'] < Time.now
99
+ refcount = 1
100
+ end
101
+
102
+ # If the refcount is 0 or somehow less than 0, after we just
103
+ # incremented, then retry without counting against the max.
104
+ if refcount < 1
105
+ retries -= 1
106
+ Locking.debug "acquire: refcount #{refcount}, unexpected state"
107
+ raise LockFailure
108
+ end
109
+
110
+ # If recount is greater than 1, we lost the race. Decrement
111
+ # and try again.
112
+ if refcount > 1
113
+ atomic_inc(target, { :refcount => -1 })
114
+ Locking.debug "acquire: refcount #{refcount}, race lost"
115
+ raise LockFailure
116
+ end
117
+
118
+ rescue LockFailure => e
119
+ retries += 1
120
+ if retries >= self.config[:max_retries]
121
+ refcounts[key] -= 1
122
+ raise LockTimeout, "unable to acquire lock #{name}"
123
+ end
124
+
125
+ Locking.warn "acquire: #{name} refcount #{refcount}, retry #{retries} for lock"
126
+
127
+ sleep(interval.to_f)
128
+ interval = [self.config[:max_retry_interval].to_f, interval * 2].min
129
+ retry
130
+
131
+ rescue => e
132
+ refcounts[key] -= 1
133
+
134
+ log_exception(e)
135
+ raise LockFailure, "unable to acquire lock #{name}"
136
+ end
137
+
138
+ Locking.info "acquire: #{name} locked (#{refcount})"
139
+
140
+ return true
141
+ end
142
+
143
+ def release(scope, key)
144
+ name = scope + "/" + key
145
+
146
+ refcounts[key] -= 1
147
+ if refcounts[key] > 0
148
+ Locking.info "release: re-using lock for #{name}##{refcounts[key]}"
149
+ return true
150
+ end
151
+
152
+ target = { :scope => scope, :key => key }
153
+
154
+ begin
155
+ refcount = atomic_inc(target, { :refcount => -1 })['refcount']
156
+
157
+ Locking.info "release: #{name} unlocked (#{refcount})"
158
+
159
+ # If the refcount is at zero, nuke it out of the table.
160
+ #
161
+ # FIXME: If the following delete fails (e.g. something else
162
+ # incremented it before we tried to delete it), it will
163
+ # raise:
164
+ #
165
+ # <Mongo::OperationFailure: Database command 'findandmodify'
166
+ # failed: {"errmsg"=>"No matching object found", "ok"=>0.0}>
167
+ #
168
+ # Need to see if there's a way to report the error
169
+ # informationally instead of exceptionally. 'rescue nil'
170
+ # hack in place until something more correct is put in.
171
+ if refcount == 0
172
+ unless hash = atomic_delete(target.merge({ :refcount => 0 })) rescue nil
173
+ Locking.debug "release: lock #{name} no longer needed, deleted"
174
+ end
175
+ end
176
+
177
+ rescue => e
178
+ log_exception(e)
179
+ raise LockFailure, "unable to release lock #{name}"
180
+ end
181
+ end
182
+
183
+ def root_for(from)
184
+ lockable = from
185
+ visited = Set.new([lockable.class])
186
+
187
+ while parent = lockable.class.locker.parent_for(lockable)
188
+ lockable = parent
189
+
190
+ if visited.include? lockable.class
191
+ raise CircularLock, "already visited #{lockable.class} (#{visited.inspect})"
192
+ end
193
+
194
+ visited << lockable.class
195
+ end
196
+
197
+ raise InvalidConfig, "root #{lockable.inspect} is not lockable" unless lockable.class.locker.is_root?
198
+
199
+ return lockable
200
+ end
201
+
202
+ def key_for(lockable)
203
+ return case key = self.config[:key]
204
+ when Proc then key.call(self).to_s
205
+ when Symbol then lockable.send(key).to_s
206
+ else raise InvalidConfig, "unknown key type #{key.inspect}"
207
+ end
208
+ end
209
+
210
+ def scope_for(lockable)
211
+ return case scope = self.config[:scope]
212
+ when Proc then scope.call(lockable).to_s
213
+ when Symbol then scope.to_s
214
+ when String then scope
215
+ else raise InvalidConfig, "unknown scope type #{scope.inspect}"
216
+ end
217
+ end
218
+
219
+ def parent_for(lockable)
220
+ return case parent = self.config[:parent]
221
+ when Proc then parent.call(self)
222
+ when Symbol then lockable.send(parent)
223
+ when NilClass then nil
224
+ else raise InvalidConfig, "unknown parent type #{parent.inspect}"
225
+ end
226
+ end
227
+
228
+ def is_root?
229
+ self.config[:parent].blank?
230
+ end
231
+
232
+ private
233
+
234
+ def log_exception(e)
235
+ Locking.error e.inspect + " " + e.backtrace[0..4].inspect
236
+ end
237
+
238
+ # Notes:
239
+ #
240
+ # - search_hash contains mapping of search_key => search_value
241
+ # - search_key is the key used to uniquely identify the document
242
+ # to update
243
+ # - search_key MUST be the sharding key if we do sharding
244
+ # later. This ensures that "$atomic" still meaningful
245
+ #
246
+ # Mongo:
247
+ #
248
+ # - $atomic actually means "write isolation". '$atomic'=>1 means
249
+ # we are the only writer to this document.
250
+ # - '$inc' means "atomic increment"
251
+ # - set expire_at for future async/lazy lock reaping
252
+
253
+ def atomic_inc(search_hash, update_hash)
254
+ return Locking.collection.find_and_modify({
255
+ :new => true, # Return the updated document
256
+ :upsert => true, # Update if document exists, insert if not
257
+ :query => search_hash.merge({'$atomic' => 1}),
258
+ :update => {
259
+ '$inc' => update_hash.dup,
260
+ '$set' => { 'expire_at' => self.config[:max_lifetime].from_now }
261
+ },
262
+ })
263
+ end
264
+
265
+ def atomic_delete(search_hash)
266
+ return Locking.collection.find_and_modify({
267
+ :query => search_hash.merge({'$atomic' => 1}),
268
+ :remove => true,
269
+ })
270
+ end
271
+
272
+ end # Locker
273
+ end # Locking
274
+ end # Mongo
@@ -0,0 +1,101 @@
1
+ require 'active_support/concern'
2
+ require 'active_support/core_ext/array/extract_options'
3
+ require 'mongo/locking'
4
+
5
+ module Mongo
6
+ module Locking
7
+
8
+ module ModelMethods
9
+ extend ::ActiveSupport::Concern
10
+
11
+ included do
12
+ # We don't want people modifying this attribute, but it needs to
13
+ # be accessible from the outside.
14
+ class << self
15
+ attr_reader :locker
16
+ end
17
+ end
18
+
19
+ module ClassMethods
20
+
21
+ # Options:
22
+ #
23
+ # :key => Symbol (sent to self), Proc (passed self) -> result.to_s
24
+ # :scope => String, Symbol (default self.name)
25
+ #
26
+ # :max_retries => default 5
27
+ # :first_retry_interval => default 0.2.seconds
28
+ # :max_retry_interval => default 5.seconds
29
+ # :max_lifetime => default 1.minute
30
+ #
31
+ def lockable!(opts = {})
32
+ key = opts[:key] ||= :id
33
+ scope = opts[:scope] ||= self.name
34
+
35
+ raise ArgumentError, "locker already defined" if self.locker
36
+ raise ArgumentError, "locker key must be a Proc or Symbol" unless [Proc, Symbol].include?(key.class)
37
+ raise ArgumentError, "locker scope must be a Proc, Symbol or String" unless [Proc, Symbol, String].include?(scope.class)
38
+
39
+ opts[:class_name] = self.name
40
+ @locker = Locker.new(opts)
41
+
42
+ Locking.debug "#{self.name} is lockable (#{opts.inspect})"
43
+
44
+ self
45
+ end
46
+
47
+ # Options:
48
+ #
49
+ # :parent => instance of lockable parent
50
+ #
51
+ def locked_by!(*args, &parent)
52
+ opts = args.extract_options!
53
+ parent = opts[:parent] ||= parent || args.first
54
+
55
+ raise ArgumentError, "locker already defined" if self.locker
56
+ raise ArgumentError, "parent reference must be a Proc or Symbol" unless [Proc, Symbol].include?(parent.class)
57
+
58
+ opts[:class_name] = self.name
59
+
60
+ @locker = Locker.new(opts)
61
+
62
+ Locking.debug "#{self.name} is locked_by (#{opts.inspect})"
63
+
64
+ self
65
+ end
66
+
67
+ end # ClassMethods
68
+
69
+ module InstanceMethods
70
+
71
+ # Main closure-based lock method. opts remains present for
72
+ # future utility.
73
+ def lock(opts = {})
74
+ raise ArgumentError, "#{self.class.name}#lock requires a block" unless block_given?
75
+
76
+ lockable = self.class.locker.lock(self)
77
+
78
+ return yield
79
+
80
+ rescue => e
81
+ Locking.error "#{self.class.name}#lock failed"
82
+ raise e
83
+
84
+ ensure
85
+ # Only unlock if lockable was set. We're using this to
86
+ # distinguish between an exception from the yield vs. an
87
+ # exception from our own locking code. Doing it in an
88
+ # ensure block makes us defensible against a return from
89
+ # within the closure, too.
90
+ #
91
+ # Calling lockable's locker instead of self potentially
92
+ # saves us the cost of "find root lockable" that locker
93
+ # would perform.
94
+ lockable.class.locker.unlock(lockable) if lockable
95
+ end
96
+
97
+ end # InstanceMethods
98
+
99
+ end # ModelMethods
100
+ end # Locking
101
+ end # Mongo
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mongo-locking
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Jordan Ritter
14
+ - Brendan Baldwin
15
+ - Yanzhu Du
16
+ autorequire:
17
+ bindir: bin
18
+ cert_chain: []
19
+
20
+ date: 2011-07-13 00:00:00 -07:00
21
+ default_executable:
22
+ dependencies:
23
+ - !ruby/object:Gem::Dependency
24
+ name: mongo
25
+ prerelease: false
26
+ requirement: &id001 !ruby/object:Gem::Requirement
27
+ none: false
28
+ requirements:
29
+ - - ~>
30
+ - !ruby/object:Gem::Version
31
+ hash: 25
32
+ segments:
33
+ - 1
34
+ - 3
35
+ - 1
36
+ version: 1.3.1
37
+ type: :runtime
38
+ version_requirements: *id001
39
+ - !ruby/object:Gem::Dependency
40
+ name: active_support
41
+ prerelease: false
42
+ requirement: &id002 !ruby/object:Gem::Requirement
43
+ none: false
44
+ requirements:
45
+ - - ~>
46
+ - !ruby/object:Gem::Version
47
+ hash: 15
48
+ segments:
49
+ - 3
50
+ - 0
51
+ - 4
52
+ version: 3.0.4
53
+ type: :runtime
54
+ version_requirements: *id002
55
+ description: A mixin DSL for implementing cross-process mutexes/locks using MongoDB
56
+ email: jpr5@serv.io
57
+ executables: []
58
+
59
+ extensions: []
60
+
61
+ extra_rdoc_files:
62
+ - README.txt
63
+ - LICENSE
64
+ files:
65
+ - LICENSE
66
+ - README.txt
67
+ - Rakefile
68
+ - lib/mongo/locking/locker.rb
69
+ - lib/mongo/locking/model_methods.rb
70
+ - lib/mongo/locking.rb
71
+ has_rdoc: true
72
+ homepage: http://github.com/servio/mongo-locking
73
+ licenses: []
74
+
75
+ post_install_message:
76
+ rdoc_options: []
77
+
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ hash: 3
86
+ segments:
87
+ - 0
88
+ version: "0"
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ none: false
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ hash: 3
95
+ segments:
96
+ - 0
97
+ version: "0"
98
+ requirements: []
99
+
100
+ rubyforge_project:
101
+ rubygems_version: 1.6.2
102
+ signing_key:
103
+ specification_version: 3
104
+ summary: A mixin DSL for implementing cross-process mutexes/locks using MongoDB
105
+ test_files: []
106
+