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 Redis key expiration we ensure locks are released even after workers crash after a configurable timout period.
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.
-
#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
37 38 39 40 41 42 |
# File 'lib/aeternitas/guard.rb', line 37 def initialize(id, cooldown, timeout = 10.minutes) @id = id @cooldown = 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 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# 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 = 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::LockWithCooldown::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 # 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. # # @example The Redis value looks like this # { # id: 'MyId' # state: 'processing' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 10:10:00' # token: '1234567890' # } # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired def acquire_lock! payload = { 'id' => @id, 'state' => 'processing', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => @timeout.from_now, 'token' => @token } has_lock = Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @timeout.to_i, nx: true) raise(GuardIsLocked.new(@id, get_timeout)) unless has_lock end # Tries to unlock the guard. This starts the cooldown phase. # # @example The Redis value looks like this # { # id: 'MyId' # state: 'cooldown' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 10:00:05' # token: '1234567890' # } def unlock return false unless holds_lock? payload = { 'id' => @id, 'state' => 'cooldown', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => @cooldown.from_now, 'token' => @token } Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @cooldown.to_i) end # Lets the guard sleep until the given date. # This means that non can acquire the guards lock # # @example The Redis value looks like this # { # id: 'MyId' # state: 'sleeping' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 13:00' # message: "API Quota Reached" # } # @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) payload = { 'id' => @id, 'state' => 'sleeping', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => sleep_timeout } payload.merge(message: msg) if msg Aeternitas.redis.set(@id, JSON.unparse(payload), ex: (sleep_timeout - Time.now).seconds.to_i) end # Checks if this instance holds the lock. This is done by retrieving the value from redis and # comparing the token value. If they match, than the lock is held by this instance. # # @todo Make the check atomic # @return [Boolean] if the lock is held by this instance def holds_lock? payload = get_payload payload['token'] == @token && payload['state'] == 'processing' end # Returns the guards current timeout. # # @return [Time] the guards current timeout def get_timeout payload = get_payload payload['state'] == 'processing' ? payload['cooldown'].to_i.seconds.from_now : Time.parse(payload['locked_until']) end # Retrieves the locks payload from redis. # # @return [Hash] the locks payload def get_payload value = Aeternitas.redis.get(@id) return {} unless value JSON.parse(value) 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 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# 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 = 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::LockWithCooldown::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 # 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. # # @example The Redis value looks like this # { # id: 'MyId' # state: 'processing' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 10:10:00' # token: '1234567890' # } # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired def acquire_lock! payload = { 'id' => @id, 'state' => 'processing', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => @timeout.from_now, 'token' => @token } has_lock = Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @timeout.to_i, nx: true) raise(GuardIsLocked.new(@id, get_timeout)) unless has_lock end # Tries to unlock the guard. This starts the cooldown phase. # # @example The Redis value looks like this # { # id: 'MyId' # state: 'cooldown' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 10:00:05' # token: '1234567890' # } def unlock return false unless holds_lock? payload = { 'id' => @id, 'state' => 'cooldown', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => @cooldown.from_now, 'token' => @token } Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @cooldown.to_i) end # Lets the guard sleep until the given date. # This means that non can acquire the guards lock # # @example The Redis value looks like this # { # id: 'MyId' # state: 'sleeping' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 13:00' # message: "API Quota Reached" # } # @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) payload = { 'id' => @id, 'state' => 'sleeping', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => sleep_timeout } payload.merge(message: msg) if msg Aeternitas.redis.set(@id, JSON.unparse(payload), ex: (sleep_timeout - Time.now).seconds.to_i) end # Checks if this instance holds the lock. This is done by retrieving the value from redis and # comparing the token value. If they match, than the lock is held by this instance. # # @todo Make the check atomic # @return [Boolean] if the lock is held by this instance def holds_lock? payload = get_payload payload['token'] == @token && payload['state'] == 'processing' end # Returns the guards current timeout. # # @return [Time] the guards current timeout def get_timeout payload = get_payload payload['state'] == 'processing' ? payload['cooldown'].to_i.seconds.from_now : Time.parse(payload['locked_until']) end # Retrieves the locks payload from redis. # # @return [Hash] the locks payload def get_payload value = Aeternitas.redis.get(@id) return {} unless value JSON.parse(value) 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 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# 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 = 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::LockWithCooldown::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 # 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. # # @example The Redis value looks like this # { # id: 'MyId' # state: 'processing' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 10:10:00' # token: '1234567890' # } # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired def acquire_lock! payload = { 'id' => @id, 'state' => 'processing', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => @timeout.from_now, 'token' => @token } has_lock = Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @timeout.to_i, nx: true) raise(GuardIsLocked.new(@id, get_timeout)) unless has_lock end # Tries to unlock the guard. This starts the cooldown phase. # # @example The Redis value looks like this # { # id: 'MyId' # state: 'cooldown' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 10:00:05' # token: '1234567890' # } def unlock return false unless holds_lock? payload = { 'id' => @id, 'state' => 'cooldown', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => @cooldown.from_now, 'token' => @token } Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @cooldown.to_i) end # Lets the guard sleep until the given date. # This means that non can acquire the guards lock # # @example The Redis value looks like this # { # id: 'MyId' # state: 'sleeping' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 13:00' # message: "API Quota Reached" # } # @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) payload = { 'id' => @id, 'state' => 'sleeping', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => sleep_timeout } payload.merge(message: msg) if msg Aeternitas.redis.set(@id, JSON.unparse(payload), ex: (sleep_timeout - Time.now).seconds.to_i) end # Checks if this instance holds the lock. This is done by retrieving the value from redis and # comparing the token value. If they match, than the lock is held by this instance. # # @todo Make the check atomic # @return [Boolean] if the lock is held by this instance def holds_lock? payload = get_payload payload['token'] == @token && payload['state'] == 'processing' end # Returns the guards current timeout. # # @return [Time] the guards current timeout def get_timeout payload = get_payload payload['state'] == 'processing' ? payload['cooldown'].to_i.seconds.from_now : Time.parse(payload['locked_until']) end # Retrieves the locks payload from redis. # # @return [Hash] the locks payload def get_payload value = Aeternitas.redis.get(@id) return {} unless value JSON.parse(value) 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 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# 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 = 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::LockWithCooldown::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 # 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. # # @example The Redis value looks like this # { # id: 'MyId' # state: 'processing' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 10:10:00' # token: '1234567890' # } # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired def acquire_lock! payload = { 'id' => @id, 'state' => 'processing', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => @timeout.from_now, 'token' => @token } has_lock = Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @timeout.to_i, nx: true) raise(GuardIsLocked.new(@id, get_timeout)) unless has_lock end # Tries to unlock the guard. This starts the cooldown phase. # # @example The Redis value looks like this # { # id: 'MyId' # state: 'cooldown' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 10:00:05' # token: '1234567890' # } def unlock return false unless holds_lock? payload = { 'id' => @id, 'state' => 'cooldown', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => @cooldown.from_now, 'token' => @token } Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @cooldown.to_i) end # Lets the guard sleep until the given date. # This means that non can acquire the guards lock # # @example The Redis value looks like this # { # id: 'MyId' # state: 'sleeping' # timeout: '3600' # cooldown: '5' # locked_until: '2017-01-01 13:00' # message: "API Quota Reached" # } # @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) payload = { 'id' => @id, 'state' => 'sleeping', 'timeout' => @timeout, 'cooldown' => @cooldown, 'locked_until' => sleep_timeout } payload.merge(message: msg) if msg Aeternitas.redis.set(@id, JSON.unparse(payload), ex: (sleep_timeout - Time.now).seconds.to_i) end # Checks if this instance holds the lock. This is done by retrieving the value from redis and # comparing the token value. If they match, than the lock is held by this instance. # # @todo Make the check atomic # @return [Boolean] if the lock is held by this instance def holds_lock? payload = get_payload payload['token'] == @token && payload['state'] == 'processing' end # Returns the guards current timeout. # # @return [Time] the guards current timeout def get_timeout payload = get_payload payload['state'] == 'processing' ? payload['cooldown'].to_i.seconds.from_now : Time.parse(payload['locked_until']) end # Retrieves the locks payload from redis. # # @return [Hash] the locks payload def get_payload value = Aeternitas.redis.get(@id) return {} unless value JSON.parse(value) 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.
70 71 72 73 |
# File 'lib/aeternitas/guard.rb', line 70 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.
62 63 64 |
# File 'lib/aeternitas/guard.rb', line 62 def sleep_until(until_time, msg = nil) sleep(until_time, msg) end |
#with_lock ⇒ Object
Runs a given block if the lock can be acquired and releases the lock afterwards.
49 50 51 52 53 54 55 56 |
# File 'lib/aeternitas/guard.rb', line 49 def with_lock acquire_lock! begin yield ensure unlock end end |