mongo-locking 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
+