Class: Aeternitas::Guard
- Inherits:
-
Object
- Object
- Aeternitas::Guard
- Defined in:
- lib/aeternitas/guard.rb
Overview
A distributed lock that can not be acquired after being unlocked for a certain time (cooldown period). Using a database table (‘aeternitas_guard_locks`) with pessimistic locking we ensure atomicity and prevent race conditions.
Defined Under Namespace
Classes: GuardIsLocked
Instance Attribute Summary collapse
-
#cooldown ⇒ ActiveSupport::Duration
readonly
Cooldown time, in which the lock can’t be acquired after being released.
-
#id ⇒ String
readonly
The guards id.
-
#timeout ⇒ ActiveSupport::Duration
readonly
The locks timeout duration.
-
#token ⇒ String
readonly
Cryptographic token which ensures we do not lock/unlock a guard held by another process.
Instance Method Summary collapse
-
#initialize(id, cooldown, timeout = 10.minutes) ⇒ Aeternitas::Guard
constructor
Create a new Guard.
-
#sleep_for(duration, msg = nil) ⇒ Object
Locks the guard for the given duration.
-
#sleep_until(until_time, msg = nil) ⇒ Object
Locks the guard until the given time.
-
#unlock ⇒ Object
Tries to unlock the guard and starts the cooldown phase.
-
#with_lock ⇒ Object
Runs a given block if the lock can be acquired and releases the lock afterwards.
Constructor Details
#initialize(id, cooldown, timeout = 10.minutes) ⇒ Aeternitas::Guard
Create a new Guard
36 37 38 39 40 41 |
# File 'lib/aeternitas/guard.rb', line 36 def initialize(id, cooldown, timeout = 10.minutes) @id = id @cooldown = Aeternitas.test_mode? ? 0.seconds : cooldown @timeout = timeout @token = SecureRandom.hex(10) end |
Instance Attribute Details
#cooldown ⇒ ActiveSupport::Duration (readonly)
Returns cooldown time, in which the lock can’t be acquired after being released.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/aeternitas/guard.rb', line 27 class Guard attr_reader :id, :timeout, :cooldown, :token # Create a new Guard # # @param [String] id Lock id # @param [ActiveRecord::Duration] cooldown Cooldown time # @param [ActiveRecord::Duration] timeout Lock timeout # @return [Aeternitas::Guard] Creates a new Instance def initialize(id, cooldown, timeout = 10.minutes) @id = id @cooldown = Aeternitas.test_mode? ? 0.seconds : cooldown @timeout = timeout @token = SecureRandom.hex(10) end # Runs a given block if the lock can be acquired and releases the lock afterwards. # # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired # @example # Guard.new("MyId", 5.seconds, 10.minutes).with_lock { do_request() } def with_lock acquire_lock! begin yield ensure unlock end end # Tries to unlock the guard and starts the cooldown phase. # It only releases the lock if the token matches and the state is 'processing'. def unlock Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id, token: @token).lock.first return false unless lock&.processing? lock.update!( state: :cooldown, locked_until: @cooldown.from_now, reason: nil ) end true end # Locks the guard until the given time. # # @param [Time] until_time sleep time # @param [String] msg hint why the guard sleeps def sleep_until(until_time, msg = nil) sleep(until_time, msg) end # Locks the guard for the given duration. # # @param [ActiveSupport::Duration] duration sleeping duration # @param [String] msg hint why the guard sleeps def sleep_for(duration, msg = nil) raise ArgumentError, "duration must be an ActiveRecord::Duration" unless duration.is_a?(ActiveSupport::Duration) sleep_until(duration.from_now, msg) end private # Tries to acquire the lock. # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired def acquire_lock! retries = 0 begin Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first if lock if lock.locked_until > Time.current # Lock is still active raise GuardIsLocked.new(@id, lock.locked_until, lock.reason) else # Lock has expired lock.update!( token: @token, state: :processing, locked_until: @timeout.from_now, reason: nil ) end else # Create new lock Aeternitas::GuardLock.create!( lock_key: @id, token: @token, state: :processing, locked_until: @timeout.from_now ) end end rescue ActiveRecord::RecordNotUnique # prevent infinite loops in unexpected scenarios retries += 1 raise if retries > 4 retry end end # Lets the guard sleep until the given time. # This will create a new sleeping lock or update an existing one. # @todo Should this raise an error if the lock is not owned by this instance? # @param [Time] sleep_timeout for how long will the guard sleep # @param [String] msg hint why the guard sleeps def sleep(sleep_timeout, msg = nil) sleep_timeout = Time.now if Aeternitas.test_mode? Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first_or_initialize lock.assign_attributes( token: @token, state: :sleeping, locked_until: sleep_timeout, reason: msg ) lock.save! end end # Custom error class thrown when the lock can not be acquired # @!attribute [r] timeout # @return [DateTime] the locks current timeout class GuardIsLocked < StandardError attr_reader :timeout def initialize(resource_id, timeout, reason = nil) msg = "Resource '#{resource_id}' is locked until #{timeout}." msg += " Reason: #{reason}" if reason super(msg) @timeout = timeout end end end |
#id ⇒ String (readonly)
Returns the guards id.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/aeternitas/guard.rb', line 27 class Guard attr_reader :id, :timeout, :cooldown, :token # Create a new Guard # # @param [String] id Lock id # @param [ActiveRecord::Duration] cooldown Cooldown time # @param [ActiveRecord::Duration] timeout Lock timeout # @return [Aeternitas::Guard] Creates a new Instance def initialize(id, cooldown, timeout = 10.minutes) @id = id @cooldown = Aeternitas.test_mode? ? 0.seconds : cooldown @timeout = timeout @token = SecureRandom.hex(10) end # Runs a given block if the lock can be acquired and releases the lock afterwards. # # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired # @example # Guard.new("MyId", 5.seconds, 10.minutes).with_lock { do_request() } def with_lock acquire_lock! begin yield ensure unlock end end # Tries to unlock the guard and starts the cooldown phase. # It only releases the lock if the token matches and the state is 'processing'. def unlock Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id, token: @token).lock.first return false unless lock&.processing? lock.update!( state: :cooldown, locked_until: @cooldown.from_now, reason: nil ) end true end # Locks the guard until the given time. # # @param [Time] until_time sleep time # @param [String] msg hint why the guard sleeps def sleep_until(until_time, msg = nil) sleep(until_time, msg) end # Locks the guard for the given duration. # # @param [ActiveSupport::Duration] duration sleeping duration # @param [String] msg hint why the guard sleeps def sleep_for(duration, msg = nil) raise ArgumentError, "duration must be an ActiveRecord::Duration" unless duration.is_a?(ActiveSupport::Duration) sleep_until(duration.from_now, msg) end private # Tries to acquire the lock. # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired def acquire_lock! retries = 0 begin Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first if lock if lock.locked_until > Time.current # Lock is still active raise GuardIsLocked.new(@id, lock.locked_until, lock.reason) else # Lock has expired lock.update!( token: @token, state: :processing, locked_until: @timeout.from_now, reason: nil ) end else # Create new lock Aeternitas::GuardLock.create!( lock_key: @id, token: @token, state: :processing, locked_until: @timeout.from_now ) end end rescue ActiveRecord::RecordNotUnique # prevent infinite loops in unexpected scenarios retries += 1 raise if retries > 4 retry end end # Lets the guard sleep until the given time. # This will create a new sleeping lock or update an existing one. # @todo Should this raise an error if the lock is not owned by this instance? # @param [Time] sleep_timeout for how long will the guard sleep # @param [String] msg hint why the guard sleeps def sleep(sleep_timeout, msg = nil) sleep_timeout = Time.now if Aeternitas.test_mode? Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first_or_initialize lock.assign_attributes( token: @token, state: :sleeping, locked_until: sleep_timeout, reason: msg ) lock.save! end end # Custom error class thrown when the lock can not be acquired # @!attribute [r] timeout # @return [DateTime] the locks current timeout class GuardIsLocked < StandardError attr_reader :timeout def initialize(resource_id, timeout, reason = nil) msg = "Resource '#{resource_id}' is locked until #{timeout}." msg += " Reason: #{reason}" if reason super(msg) @timeout = timeout end end end |
#timeout ⇒ ActiveSupport::Duration (readonly)
Returns the locks timeout duration.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/aeternitas/guard.rb', line 27 class Guard attr_reader :id, :timeout, :cooldown, :token # Create a new Guard # # @param [String] id Lock id # @param [ActiveRecord::Duration] cooldown Cooldown time # @param [ActiveRecord::Duration] timeout Lock timeout # @return [Aeternitas::Guard] Creates a new Instance def initialize(id, cooldown, timeout = 10.minutes) @id = id @cooldown = Aeternitas.test_mode? ? 0.seconds : cooldown @timeout = timeout @token = SecureRandom.hex(10) end # Runs a given block if the lock can be acquired and releases the lock afterwards. # # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired # @example # Guard.new("MyId", 5.seconds, 10.minutes).with_lock { do_request() } def with_lock acquire_lock! begin yield ensure unlock end end # Tries to unlock the guard and starts the cooldown phase. # It only releases the lock if the token matches and the state is 'processing'. def unlock Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id, token: @token).lock.first return false unless lock&.processing? lock.update!( state: :cooldown, locked_until: @cooldown.from_now, reason: nil ) end true end # Locks the guard until the given time. # # @param [Time] until_time sleep time # @param [String] msg hint why the guard sleeps def sleep_until(until_time, msg = nil) sleep(until_time, msg) end # Locks the guard for the given duration. # # @param [ActiveSupport::Duration] duration sleeping duration # @param [String] msg hint why the guard sleeps def sleep_for(duration, msg = nil) raise ArgumentError, "duration must be an ActiveRecord::Duration" unless duration.is_a?(ActiveSupport::Duration) sleep_until(duration.from_now, msg) end private # Tries to acquire the lock. # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired def acquire_lock! retries = 0 begin Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first if lock if lock.locked_until > Time.current # Lock is still active raise GuardIsLocked.new(@id, lock.locked_until, lock.reason) else # Lock has expired lock.update!( token: @token, state: :processing, locked_until: @timeout.from_now, reason: nil ) end else # Create new lock Aeternitas::GuardLock.create!( lock_key: @id, token: @token, state: :processing, locked_until: @timeout.from_now ) end end rescue ActiveRecord::RecordNotUnique # prevent infinite loops in unexpected scenarios retries += 1 raise if retries > 4 retry end end # Lets the guard sleep until the given time. # This will create a new sleeping lock or update an existing one. # @todo Should this raise an error if the lock is not owned by this instance? # @param [Time] sleep_timeout for how long will the guard sleep # @param [String] msg hint why the guard sleeps def sleep(sleep_timeout, msg = nil) sleep_timeout = Time.now if Aeternitas.test_mode? Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first_or_initialize lock.assign_attributes( token: @token, state: :sleeping, locked_until: sleep_timeout, reason: msg ) lock.save! end end # Custom error class thrown when the lock can not be acquired # @!attribute [r] timeout # @return [DateTime] the locks current timeout class GuardIsLocked < StandardError attr_reader :timeout def initialize(resource_id, timeout, reason = nil) msg = "Resource '#{resource_id}' is locked until #{timeout}." msg += " Reason: #{reason}" if reason super(msg) @timeout = timeout end end end |
#token ⇒ String (readonly)
Returns cryptographic token which ensures we do not lock/unlock a guard held by another process.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/aeternitas/guard.rb', line 27 class Guard attr_reader :id, :timeout, :cooldown, :token # Create a new Guard # # @param [String] id Lock id # @param [ActiveRecord::Duration] cooldown Cooldown time # @param [ActiveRecord::Duration] timeout Lock timeout # @return [Aeternitas::Guard] Creates a new Instance def initialize(id, cooldown, timeout = 10.minutes) @id = id @cooldown = Aeternitas.test_mode? ? 0.seconds : cooldown @timeout = timeout @token = SecureRandom.hex(10) end # Runs a given block if the lock can be acquired and releases the lock afterwards. # # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired # @example # Guard.new("MyId", 5.seconds, 10.minutes).with_lock { do_request() } def with_lock acquire_lock! begin yield ensure unlock end end # Tries to unlock the guard and starts the cooldown phase. # It only releases the lock if the token matches and the state is 'processing'. def unlock Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id, token: @token).lock.first return false unless lock&.processing? lock.update!( state: :cooldown, locked_until: @cooldown.from_now, reason: nil ) end true end # Locks the guard until the given time. # # @param [Time] until_time sleep time # @param [String] msg hint why the guard sleeps def sleep_until(until_time, msg = nil) sleep(until_time, msg) end # Locks the guard for the given duration. # # @param [ActiveSupport::Duration] duration sleeping duration # @param [String] msg hint why the guard sleeps def sleep_for(duration, msg = nil) raise ArgumentError, "duration must be an ActiveRecord::Duration" unless duration.is_a?(ActiveSupport::Duration) sleep_until(duration.from_now, msg) end private # Tries to acquire the lock. # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired def acquire_lock! retries = 0 begin Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first if lock if lock.locked_until > Time.current # Lock is still active raise GuardIsLocked.new(@id, lock.locked_until, lock.reason) else # Lock has expired lock.update!( token: @token, state: :processing, locked_until: @timeout.from_now, reason: nil ) end else # Create new lock Aeternitas::GuardLock.create!( lock_key: @id, token: @token, state: :processing, locked_until: @timeout.from_now ) end end rescue ActiveRecord::RecordNotUnique # prevent infinite loops in unexpected scenarios retries += 1 raise if retries > 4 retry end end # Lets the guard sleep until the given time. # This will create a new sleeping lock or update an existing one. # @todo Should this raise an error if the lock is not owned by this instance? # @param [Time] sleep_timeout for how long will the guard sleep # @param [String] msg hint why the guard sleeps def sleep(sleep_timeout, msg = nil) sleep_timeout = Time.now if Aeternitas.test_mode? Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first_or_initialize lock.assign_attributes( token: @token, state: :sleeping, locked_until: sleep_timeout, reason: msg ) lock.save! end end # Custom error class thrown when the lock can not be acquired # @!attribute [r] timeout # @return [DateTime] the locks current timeout class GuardIsLocked < StandardError attr_reader :timeout def initialize(resource_id, timeout, reason = nil) msg = "Resource '#{resource_id}' is locked until #{timeout}." msg += " Reason: #{reason}" if reason super(msg) @timeout = timeout end end end |
Instance Method Details
#sleep_for(duration, msg = nil) ⇒ Object
Locks the guard for the given duration.
85 86 87 88 |
# File 'lib/aeternitas/guard.rb', line 85 def sleep_for(duration, msg = nil) raise ArgumentError, "duration must be an ActiveRecord::Duration" unless duration.is_a?(ActiveSupport::Duration) sleep_until(duration.from_now, msg) end |
#sleep_until(until_time, msg = nil) ⇒ Object
Locks the guard until the given time.
77 78 79 |
# File 'lib/aeternitas/guard.rb', line 77 def sleep_until(until_time, msg = nil) sleep(until_time, msg) end |
#unlock ⇒ Object
Tries to unlock the guard and starts the cooldown phase. It only releases the lock if the token matches and the state is ‘processing’.
59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/aeternitas/guard.rb', line 59 def unlock Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id, token: @token).lock.first return false unless lock&.processing? lock.update!( state: :cooldown, locked_until: @cooldown.from_now, reason: nil ) end true end |
#with_lock ⇒ Object
Runs a given block if the lock can be acquired and releases the lock afterwards.
48 49 50 51 52 53 54 55 |
# File 'lib/aeternitas/guard.rb', line 48 def with_lock acquire_lock! begin yield ensure unlock end end |