Class: CountingSemaphore::RedisSemaphore
- Inherits:
-
Object
- Object
- CountingSemaphore::RedisSemaphore
- Includes:
- WithLeaseSupport
- Defined in:
- lib/counting_semaphore/redis_semaphore.rb
Defined Under Namespace
Classes: NullPool
Constant Summary collapse
- LEASE_EXPIRATION_SECONDS =
5- GET_LEASE_SCRIPT =
Lua script for atomic lease acquisition Returns: [success, lease_key, current_usage] success: 1 if lease was acquired, 0 if no capacity lease_key: the key of the acquired lease (if successful) current_usage: current usage count after operation
<<~LUA local lease_key = KEYS[1] local lease_set_key = KEYS[2] local capacity = tonumber(ARGV[1]) local permit_count = tonumber(ARGV[2]) local expiration_seconds = tonumber(ARGV[3]) -- Get all active leases from the set and calculate current usage local lease_keys = redis.call('SMEMBERS', lease_set_key) local current_usage = 0 local valid_leases = {} for i, key in ipairs(lease_keys) do local permits = redis.call('GET', key) if permits then local permits_from_lease = tonumber(permits) if permits_from_lease then current_usage = current_usage + permits_from_lease table.insert(valid_leases, key) else -- Remove lease with invalid permit count redis.call('DEL', key) redis.call('SREM', lease_set_key, key) end else -- Lease key doesn't exist, remove from set redis.call('SREM', lease_set_key, key) end end -- Check if we have capacity local available = capacity - current_usage if available >= permit_count then -- Set lease with TTL (value is just the permit count) redis.call('SETEX', lease_key, expiration_seconds, permit_count) -- Add lease key to the set redis.call('SADD', lease_set_key, lease_key) -- Set TTL on the set (4x the lease TTL to ensure cleanup) redis.call('EXPIRE', lease_set_key, expiration_seconds * 4) return {1, lease_key, current_usage + permit_count} else return {0, '', current_usage} end LUA
- GET_USAGE_SCRIPT =
Lua script for getting current usage Returns: current_usage (integer)
<<~LUA local lease_set_key = KEYS[1] local expiration_seconds = tonumber(ARGV[1]) -- Get all active leases from the set and calculate current usage local lease_keys = redis.call('SMEMBERS', lease_set_key) local current_usage = 0 local has_valid_leases = false for i, lease_key in ipairs(lease_keys) do local permits = redis.call('GET', lease_key) if permits then local permits_from_lease = tonumber(permits) if permits_from_lease then current_usage = current_usage + permits_from_lease has_valid_leases = true else -- Remove lease with invalid permit count redis.call('DEL', lease_key) redis.call('SREM', lease_set_key, lease_key) end else -- Lease key doesn't exist, remove from set redis.call('SREM', lease_set_key, lease_key) end end -- Refresh TTL on the set if there are valid leases (4x the lease TTL) if has_valid_leases then redis.call('EXPIRE', lease_set_key, expiration_seconds * 4) end return current_usage LUA
- RELEASE_LEASE_SCRIPT =
Lua script for atomic lease release and signal Returns: 1 (success)
<<~LUA local lease_key = KEYS[1] local queue_key = KEYS[2] local lease_set_key = KEYS[3] local permit_count = tonumber(ARGV[1]) local max_signals = tonumber(ARGV[2]) -- Remove the lease redis.call('DEL', lease_key) -- Remove from the lease set redis.call('SREM', lease_set_key, lease_key) -- Signal waiting clients about the released permits redis.call('LPUSH', queue_key, 'permits:' .. permit_count) -- Trim queue to prevent indefinite growth (atomic) redis.call('LTRIM', queue_key, 0, max_signals - 1) return 1 LUA
- GET_LEASE_SCRIPT_SHA =
Precomputed script SHAs
Digest::SHA1.hexdigest(GET_LEASE_SCRIPT)
- GET_USAGE_SCRIPT_SHA =
Digest::SHA1.hexdigest(GET_USAGE_SCRIPT)
- RELEASE_LEASE_SCRIPT_SHA =
Digest::SHA1.hexdigest(RELEASE_LEASE_SCRIPT)
Instance Attribute Summary collapse
- #capacity ⇒ Integer readonly
Instance Method Summary collapse
-
#acquire(permits = 1) ⇒ CountingSemaphore::Lease
Acquires the given number of permits from this semaphore, blocking until all are available.
-
#available_permits ⇒ Integer
Returns the current number of permits available in this semaphore.
-
#debug_info ⇒ Hash
Returns debugging information about the current state of the semaphore.
-
#drain_permits ⇒ CountingSemaphore::Lease?
Acquires and returns all permits that are immediately available.
-
#initialize(capacity, namespace, redis: nil, logger: CountingSemaphore::NullLogger, lease_expiration_seconds: LEASE_EXPIRATION_SECONDS) ⇒ RedisSemaphore
constructor
Initialize the semaphore with a maximum capacity and required namespace.
-
#release(lease) ⇒ nil
Releases a previously acquired lease, returning the permits to the semaphore.
-
#try_acquire(permits = 1, timeout: nil) ⇒ CountingSemaphore::Lease?
Acquires the given number of permits from this semaphore, only if all are available at the time of invocation or within the timeout interval.
Methods included from WithLeaseSupport
#currently_leased, #with_lease
Constructor Details
#initialize(capacity, namespace, redis: nil, logger: CountingSemaphore::NullLogger, lease_expiration_seconds: LEASE_EXPIRATION_SECONDS) ⇒ RedisSemaphore
Initialize the semaphore with a maximum capacity and required namespace.
142 143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/counting_semaphore/redis_semaphore.rb', line 142 def initialize(capacity, namespace, redis: nil, logger: CountingSemaphore::NullLogger, lease_expiration_seconds: LEASE_EXPIRATION_SECONDS) raise ArgumentError, "Capacity must be positive, got #{capacity}" unless capacity > 0 # Require Redis only when RedisSemaphore is used require "redis" unless defined?(Redis) @capacity = capacity @redis_connection_pool = wrap_redis_client_with_pool(redis || Redis.new) @namespace = namespace @lease_expiration_seconds = lease_expiration_seconds @logger = logger # Scripts are precomputed and will be loaded on-demand if needed end |
Instance Attribute Details
#capacity ⇒ Integer (readonly)
133 134 135 |
# File 'lib/counting_semaphore/redis_semaphore.rb', line 133 def capacity @capacity end |
Instance Method Details
#acquire(permits = 1) ⇒ CountingSemaphore::Lease
Acquires the given number of permits from this semaphore, blocking until all are available.
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/counting_semaphore/redis_semaphore.rb', line 182 def acquire(permits = 1) raise ArgumentError, "Permits must be at least 1, got #{permits}" if permits < 1 if permits > @capacity raise ArgumentError, "Cannot acquire #{permits} permits as capacity is only #{@capacity}" end lease_key = acquire_lease_internal(permits, timeout_seconds: nil) @logger.debug { "Acquired #{permits} permits with lease #{lease_key}" } CountingSemaphore::Lease.new( semaphore: self, id: lease_key, permits: permits ) end |
#available_permits ⇒ Integer
Returns the current number of permits available in this semaphore.
244 245 246 247 |
# File 'lib/counting_semaphore/redis_semaphore.rb', line 244 def available_permits current_usage = get_current_usage @capacity - current_usage end |
#debug_info ⇒ Hash
Returns debugging information about the current state of the semaphore. Includes current usage, capacity, available permits, and details about active leases.
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/counting_semaphore/redis_semaphore.rb', line 269 def debug_info usage = get_current_usage lease_set_key = "#{@namespace}:lease_set" lease_keys = with_redis { |redis| redis.smembers(lease_set_key) } active_leases = [] lease_keys.each do |lease_key| permits = with_redis { |redis| redis.get(lease_key) } next unless permits active_leases << { key: lease_key, permits: permits.to_i } end { usage: usage, capacity: @capacity, available: @capacity - usage, active_leases: active_leases } end |
#drain_permits ⇒ CountingSemaphore::Lease?
Acquires and returns all permits that are immediately available. Note: For distributed semaphores, this may not be perfectly accurate due to race conditions.
253 254 255 256 257 258 259 |
# File 'lib/counting_semaphore/redis_semaphore.rb', line 253 def drain_permits available = available_permits return nil if available <= 0 # Try to acquire all available permits try_acquire(available, timeout: 0.1) end |
#release(lease) ⇒ nil
Releases a previously acquired lease, returning the permits to the semaphore.
203 204 205 206 207 208 209 210 |
# File 'lib/counting_semaphore/redis_semaphore.rb', line 203 def release(lease) unless lease.semaphore == self raise ArgumentError, "Lease belongs to a different semaphore" end release_lease(lease.id, lease.permits) nil end |
#try_acquire(permits = 1, timeout: nil) ⇒ CountingSemaphore::Lease?
Acquires the given number of permits from this semaphore, only if all are available at the time of invocation or within the timeout interval.
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 |
# File 'lib/counting_semaphore/redis_semaphore.rb', line 220 def try_acquire(permits = 1, timeout: nil) raise ArgumentError, "Permits must be at least 1, got #{permits}" if permits < 1 if permits > @capacity return nil end timeout_seconds = timeout.nil? ? 0.1 : timeout begin lease_key = acquire_lease_internal(permits, timeout_seconds: timeout_seconds) @logger.debug { "Acquired #{permits} permits (try) with lease #{lease_key}" } CountingSemaphore::Lease.new( semaphore: self, id: lease_key, permits: permits ) rescue CountingSemaphore::LeaseTimeout nil end end |