counting_semaphore 0.1.0 → 0.2.0
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.
- checksums.yaml +4 -4
- data/README.md +167 -21
- data/Rakefile +6 -1
- data/lib/counting_semaphore/local_semaphore.rb +129 -47
- data/lib/counting_semaphore/null_logger.rb +29 -2
- data/lib/counting_semaphore/redis_semaphore.rb +175 -86
- data/lib/counting_semaphore/version.rb +3 -1
- data/lib/counting_semaphore/with_lease_support.rb +60 -0
- data/lib/counting_semaphore.rb +39 -5
- data/rbi/counting_semaphore.rbi +517 -0
- data/sig/counting_semaphore.rbs +367 -0
- data/test/counting_semaphore/local_semaphore_test.rb +365 -3
- data/test/counting_semaphore/redis_semaphore_test.rb +423 -9
- metadata +19 -4
- data/Gemfile.lock +0 -76
- data/lib/counting_semaphore/shared_semaphore.rb +0 -381
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# A distributed counting semaphore that allows up to N concurrent operations across multiple processes.
|
|
2
4
|
# Uses Redis for coordination and automatically handles lease expiration for crashed processes.
|
|
3
5
|
# Uses Redis Lua scripts for atomic operations to prevent race conditions.
|
|
6
|
+
# API compatible with concurrent-ruby's Semaphore class.
|
|
4
7
|
require "digest"
|
|
5
8
|
require "securerandom"
|
|
6
9
|
|
|
7
10
|
module CountingSemaphore
|
|
8
11
|
class RedisSemaphore
|
|
12
|
+
include WithLeaseSupport
|
|
13
|
+
|
|
9
14
|
LEASE_EXPIRATION_SECONDS = 5
|
|
10
15
|
|
|
11
16
|
# Lua script for atomic lease acquisition
|
|
@@ -17,7 +22,7 @@ module CountingSemaphore
|
|
|
17
22
|
local lease_key = KEYS[1]
|
|
18
23
|
local lease_set_key = KEYS[2]
|
|
19
24
|
local capacity = tonumber(ARGV[1])
|
|
20
|
-
local
|
|
25
|
+
local permit_count = tonumber(ARGV[2])
|
|
21
26
|
local expiration_seconds = tonumber(ARGV[3])
|
|
22
27
|
|
|
23
28
|
-- Get all active leases from the set and calculate current usage
|
|
@@ -26,14 +31,14 @@ module CountingSemaphore
|
|
|
26
31
|
local valid_leases = {}
|
|
27
32
|
|
|
28
33
|
for i, key in ipairs(lease_keys) do
|
|
29
|
-
local
|
|
30
|
-
if
|
|
31
|
-
local
|
|
32
|
-
if
|
|
33
|
-
current_usage = current_usage +
|
|
34
|
+
local permits = redis.call('GET', key)
|
|
35
|
+
if permits then
|
|
36
|
+
local permits_from_lease = tonumber(permits)
|
|
37
|
+
if permits_from_lease then
|
|
38
|
+
current_usage = current_usage + permits_from_lease
|
|
34
39
|
table.insert(valid_leases, key)
|
|
35
40
|
else
|
|
36
|
-
-- Remove lease with invalid
|
|
41
|
+
-- Remove lease with invalid permit count
|
|
37
42
|
redis.call('DEL', key)
|
|
38
43
|
redis.call('SREM', lease_set_key, key)
|
|
39
44
|
end
|
|
@@ -45,15 +50,15 @@ module CountingSemaphore
|
|
|
45
50
|
|
|
46
51
|
-- Check if we have capacity
|
|
47
52
|
local available = capacity - current_usage
|
|
48
|
-
if available >=
|
|
49
|
-
-- Set lease with TTL (value is just the
|
|
50
|
-
redis.call('SETEX', lease_key, expiration_seconds,
|
|
53
|
+
if available >= permit_count then
|
|
54
|
+
-- Set lease with TTL (value is just the permit count)
|
|
55
|
+
redis.call('SETEX', lease_key, expiration_seconds, permit_count)
|
|
51
56
|
-- Add lease key to the set
|
|
52
57
|
redis.call('SADD', lease_set_key, lease_key)
|
|
53
58
|
-- Set TTL on the set (4x the lease TTL to ensure cleanup)
|
|
54
59
|
redis.call('EXPIRE', lease_set_key, expiration_seconds * 4)
|
|
55
60
|
|
|
56
|
-
return {1, lease_key, current_usage +
|
|
61
|
+
return {1, lease_key, current_usage + permit_count}
|
|
57
62
|
else
|
|
58
63
|
return {0, '', current_usage}
|
|
59
64
|
end
|
|
@@ -71,14 +76,14 @@ module CountingSemaphore
|
|
|
71
76
|
local has_valid_leases = false
|
|
72
77
|
|
|
73
78
|
for i, lease_key in ipairs(lease_keys) do
|
|
74
|
-
local
|
|
75
|
-
if
|
|
76
|
-
local
|
|
77
|
-
if
|
|
78
|
-
current_usage = current_usage +
|
|
79
|
+
local permits = redis.call('GET', lease_key)
|
|
80
|
+
if permits then
|
|
81
|
+
local permits_from_lease = tonumber(permits)
|
|
82
|
+
if permits_from_lease then
|
|
83
|
+
current_usage = current_usage + permits_from_lease
|
|
79
84
|
has_valid_leases = true
|
|
80
85
|
else
|
|
81
|
-
-- Remove lease with invalid
|
|
86
|
+
-- Remove lease with invalid permit count
|
|
82
87
|
redis.call('DEL', lease_key)
|
|
83
88
|
redis.call('SREM', lease_set_key, lease_key)
|
|
84
89
|
end
|
|
@@ -102,7 +107,7 @@ module CountingSemaphore
|
|
|
102
107
|
local lease_key = KEYS[1]
|
|
103
108
|
local queue_key = KEYS[2]
|
|
104
109
|
local lease_set_key = KEYS[3]
|
|
105
|
-
local
|
|
110
|
+
local permit_count = tonumber(ARGV[1])
|
|
106
111
|
local max_signals = tonumber(ARGV[2])
|
|
107
112
|
|
|
108
113
|
-- Remove the lease
|
|
@@ -110,8 +115,8 @@ module CountingSemaphore
|
|
|
110
115
|
-- Remove from the lease set
|
|
111
116
|
redis.call('SREM', lease_set_key, lease_key)
|
|
112
117
|
|
|
113
|
-
-- Signal waiting clients about the released
|
|
114
|
-
redis.call('LPUSH', queue_key, '
|
|
118
|
+
-- Signal waiting clients about the released permits
|
|
119
|
+
redis.call('LPUSH', queue_key, 'permits:' .. permit_count)
|
|
115
120
|
|
|
116
121
|
-- Trim queue to prevent indefinite growth (atomic)
|
|
117
122
|
redis.call('LTRIM', queue_key, 0, max_signals - 1)
|
|
@@ -129,7 +134,7 @@ module CountingSemaphore
|
|
|
129
134
|
|
|
130
135
|
# Initialize the semaphore with a maximum capacity and required namespace.
|
|
131
136
|
#
|
|
132
|
-
# @param capacity [Integer] Maximum number of concurrent operations allowed
|
|
137
|
+
# @param capacity [Integer] Maximum number of concurrent operations allowed (also called permits)
|
|
133
138
|
# @param namespace [String] Required namespace for Redis keys
|
|
134
139
|
# @param redis [Redis, ConnectionPool] Optional Redis client or connection pool (defaults to new Redis instance)
|
|
135
140
|
# @param logger [Logger] the logger
|
|
@@ -137,7 +142,7 @@ module CountingSemaphore
|
|
|
137
142
|
def initialize(capacity, namespace, redis: nil, logger: CountingSemaphore::NullLogger, lease_expiration_seconds: LEASE_EXPIRATION_SECONDS)
|
|
138
143
|
raise ArgumentError, "Capacity must be positive, got #{capacity}" unless capacity > 0
|
|
139
144
|
|
|
140
|
-
# Require Redis only when
|
|
145
|
+
# Require Redis only when RedisSemaphore is used
|
|
141
146
|
require "redis" unless defined?(Redis)
|
|
142
147
|
|
|
143
148
|
@capacity = capacity
|
|
@@ -149,52 +154,118 @@ module CountingSemaphore
|
|
|
149
154
|
# Scripts are precomputed and will be loaded on-demand if needed
|
|
150
155
|
end
|
|
151
156
|
|
|
152
|
-
# Null pool for bare Redis connections that don't need connection pooling
|
|
157
|
+
# Null pool for bare Redis connections that don't need connection pooling.
|
|
158
|
+
# Provides a compatible interface with ConnectionPool for bare Redis instances.
|
|
153
159
|
class NullPool
|
|
160
|
+
# Creates a new NullPool wrapper around a Redis connection.
|
|
161
|
+
#
|
|
162
|
+
# @param redis_connection [Redis] The Redis connection to wrap
|
|
154
163
|
def initialize(redis_connection)
|
|
155
164
|
@redis_connection = redis_connection
|
|
156
165
|
end
|
|
157
166
|
|
|
167
|
+
# Yields the wrapped Redis connection to the block.
|
|
168
|
+
# Provides ConnectionPool-compatible interface.
|
|
169
|
+
#
|
|
170
|
+
# @yield [redis] The Redis connection
|
|
171
|
+
# @return The result of the block
|
|
158
172
|
def with(&block)
|
|
159
173
|
block.call(@redis_connection)
|
|
160
174
|
end
|
|
161
175
|
end
|
|
162
176
|
|
|
163
|
-
#
|
|
164
|
-
# Blocks until sufficient resources are available.
|
|
177
|
+
# Acquires the given number of permits from this semaphore, blocking until all are available.
|
|
165
178
|
#
|
|
166
|
-
# @param
|
|
167
|
-
# @
|
|
168
|
-
# @
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
raise ArgumentError, "Token count must be non-negative, got #{token_count}" if token_count < 0
|
|
174
|
-
if token_count > @capacity
|
|
175
|
-
raise ArgumentError, "Cannot lease #{token_count} slots as we only allow #{@capacity}"
|
|
179
|
+
# @param permits [Integer] Number of permits to acquire (default: 1)
|
|
180
|
+
# @return [CountingSemaphore::Lease] A lease object that must be passed to release()
|
|
181
|
+
# @raise [ArgumentError] if permits is not an integer or is less than one
|
|
182
|
+
def acquire(permits = 1)
|
|
183
|
+
raise ArgumentError, "Permits must be at least 1, got #{permits}" if permits < 1
|
|
184
|
+
if permits > @capacity
|
|
185
|
+
raise ArgumentError, "Cannot acquire #{permits} permits as capacity is only #{@capacity}"
|
|
176
186
|
end
|
|
177
187
|
|
|
178
|
-
|
|
179
|
-
|
|
188
|
+
lease_key = acquire_lease_internal(permits, timeout_seconds: nil)
|
|
189
|
+
@logger.debug { "Acquired #{permits} permits with lease #{lease_key}" }
|
|
190
|
+
|
|
191
|
+
CountingSemaphore::Lease.new(
|
|
192
|
+
semaphore: self,
|
|
193
|
+
id: lease_key,
|
|
194
|
+
permits: permits
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Releases a previously acquired lease, returning the permits to the semaphore.
|
|
199
|
+
#
|
|
200
|
+
# @param lease [CountingSemaphore::Lease] The lease object returned by acquire() or try_acquire()
|
|
201
|
+
# @return [nil]
|
|
202
|
+
# @raise [ArgumentError] if lease belongs to a different semaphore
|
|
203
|
+
def release(lease)
|
|
204
|
+
unless lease.semaphore == self
|
|
205
|
+
raise ArgumentError, "Lease belongs to a different semaphore"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
release_lease(lease.id, lease.permits)
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Acquires the given number of permits from this semaphore, only if all are available
|
|
213
|
+
# at the time of invocation or within the timeout interval.
|
|
214
|
+
#
|
|
215
|
+
# @param permits [Integer] Number of permits to acquire (default: 1)
|
|
216
|
+
# @param timeout [Numeric, nil] Number of seconds to wait, or nil to return immediately (default: nil).
|
|
217
|
+
# The timeout value will be rounded up to the nearest whole second due to Redis BLPOP limitations.
|
|
218
|
+
# @return [CountingSemaphore::Lease, nil] A lease object if successful, nil otherwise
|
|
219
|
+
# @raise [ArgumentError] if permits is not an integer or is less than one
|
|
220
|
+
def try_acquire(permits = 1, timeout: nil)
|
|
221
|
+
raise ArgumentError, "Permits must be at least 1, got #{permits}" if permits < 1
|
|
222
|
+
if permits > @capacity
|
|
223
|
+
return nil
|
|
224
|
+
end
|
|
180
225
|
|
|
181
|
-
|
|
226
|
+
timeout_seconds = timeout.nil? ? 0.1 : timeout
|
|
182
227
|
begin
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
228
|
+
lease_key = acquire_lease_internal(permits, timeout_seconds: timeout_seconds)
|
|
229
|
+
@logger.debug { "Acquired #{permits} permits (try) with lease #{lease_key}" }
|
|
230
|
+
|
|
231
|
+
CountingSemaphore::Lease.new(
|
|
232
|
+
semaphore: self,
|
|
233
|
+
id: lease_key,
|
|
234
|
+
permits: permits
|
|
235
|
+
)
|
|
236
|
+
rescue CountingSemaphore::LeaseTimeout
|
|
237
|
+
nil
|
|
187
238
|
end
|
|
188
239
|
end
|
|
189
240
|
|
|
190
|
-
#
|
|
241
|
+
# Returns the current number of permits available in this semaphore.
|
|
242
|
+
#
|
|
243
|
+
# @return [Integer] Number of available permits
|
|
244
|
+
def available_permits
|
|
245
|
+
current_usage = get_current_usage
|
|
246
|
+
@capacity - current_usage
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Acquires and returns all permits that are immediately available.
|
|
250
|
+
# Note: For distributed semaphores, this may not be perfectly accurate due to race conditions.
|
|
191
251
|
#
|
|
192
|
-
# @return [
|
|
193
|
-
def
|
|
194
|
-
|
|
252
|
+
# @return [CountingSemaphore::Lease, nil] A lease for all available permits, or nil if none available
|
|
253
|
+
def drain_permits
|
|
254
|
+
available = available_permits
|
|
255
|
+
return nil if available <= 0
|
|
256
|
+
|
|
257
|
+
# Try to acquire all available permits
|
|
258
|
+
try_acquire(available, timeout: 0.1)
|
|
195
259
|
end
|
|
196
260
|
|
|
197
|
-
#
|
|
261
|
+
# Returns debugging information about the current state of the semaphore.
|
|
262
|
+
# Includes current usage, capacity, available permits, and details about active leases.
|
|
263
|
+
#
|
|
264
|
+
# @return [Hash] A hash containing :usage, :capacity, :available, and :active_leases
|
|
265
|
+
# @example
|
|
266
|
+
# info = semaphore.debug_info
|
|
267
|
+
# puts "Usage: #{info[:usage]}/#{info[:capacity]}"
|
|
268
|
+
# info[:active_leases].each { |lease| puts "Lease: #{lease[:key]} - #{lease[:permits]} permits" }
|
|
198
269
|
def debug_info
|
|
199
270
|
usage = get_current_usage
|
|
200
271
|
lease_set_key = "#{@namespace}:lease_set"
|
|
@@ -202,12 +273,12 @@ module CountingSemaphore
|
|
|
202
273
|
active_leases = []
|
|
203
274
|
|
|
204
275
|
lease_keys.each do |lease_key|
|
|
205
|
-
|
|
206
|
-
next unless
|
|
276
|
+
permits = with_redis { |redis| redis.get(lease_key) }
|
|
277
|
+
next unless permits
|
|
207
278
|
|
|
208
279
|
active_leases << {
|
|
209
280
|
key: lease_key,
|
|
210
|
-
|
|
281
|
+
permits: permits.to_i
|
|
211
282
|
}
|
|
212
283
|
end
|
|
213
284
|
|
|
@@ -257,7 +328,7 @@ module CountingSemaphore
|
|
|
257
328
|
end
|
|
258
329
|
rescue Redis::CommandError => e
|
|
259
330
|
if e.message.include?("NOSCRIPT")
|
|
260
|
-
@logger.debug "
|
|
331
|
+
@logger.debug { "Script not found, using EVAL: #{e.message}" }
|
|
261
332
|
# Fall back to EVAL with the script body
|
|
262
333
|
with_redis do |redis|
|
|
263
334
|
redis.eval(script_body, keys: keys, argv: argv)
|
|
@@ -267,54 +338,72 @@ module CountingSemaphore
|
|
|
267
338
|
end
|
|
268
339
|
end
|
|
269
340
|
|
|
270
|
-
def
|
|
271
|
-
|
|
341
|
+
def acquire_lease_internal(permit_count, timeout_seconds:)
|
|
342
|
+
# If timeout is nil, wait indefinitely (for acquire method)
|
|
343
|
+
if timeout_seconds.nil?
|
|
344
|
+
loop do
|
|
345
|
+
lease_key = attempt_lease_acquisition(permit_count)
|
|
346
|
+
return lease_key if lease_key
|
|
272
347
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
276
|
-
if elapsed_time >= timeout_seconds
|
|
277
|
-
raise CountingSemaphore::LeaseTimeout.new(token_count, timeout_seconds, self)
|
|
348
|
+
# Wait for signals indefinitely
|
|
349
|
+
wait_for_permits(permit_count, nil)
|
|
278
350
|
end
|
|
351
|
+
else
|
|
352
|
+
# Wait with timeout (for with_lease and try_acquire)
|
|
353
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
354
|
+
|
|
355
|
+
loop do
|
|
356
|
+
# Check if we've exceeded the timeout using monotonic time
|
|
357
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
358
|
+
if elapsed_time >= timeout_seconds
|
|
359
|
+
raise CountingSemaphore::LeaseTimeout.new(permit_count, timeout_seconds, self)
|
|
360
|
+
end
|
|
279
361
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
362
|
+
# Try optimistic acquisition first
|
|
363
|
+
lease_key = attempt_lease_acquisition(permit_count)
|
|
364
|
+
return lease_key if lease_key
|
|
283
365
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
366
|
+
# If failed, wait for signals with timeout
|
|
367
|
+
lease_key = wait_for_permits(permit_count, timeout_seconds - elapsed_time)
|
|
368
|
+
return lease_key if lease_key
|
|
369
|
+
end
|
|
287
370
|
end
|
|
288
371
|
end
|
|
289
372
|
|
|
290
|
-
def
|
|
291
|
-
#
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
373
|
+
def wait_for_permits(permit_count, remaining_timeout)
|
|
374
|
+
# If remaining_timeout is nil, wait indefinitely
|
|
375
|
+
if remaining_timeout.nil?
|
|
376
|
+
timeout = @lease_expiration_seconds + 2
|
|
377
|
+
@logger.debug { "Unable to acquire #{permit_count} permits, waiting for signals (indefinite)" }
|
|
378
|
+
else
|
|
379
|
+
# Ensure minimum timeout to prevent infinite blocking
|
|
380
|
+
# BLPOP with timeout 0 blocks forever, so we need at least a small positive timeout
|
|
381
|
+
minimum_timeout = 0.1
|
|
382
|
+
if remaining_timeout <= minimum_timeout
|
|
383
|
+
@logger.debug { "Remaining timeout (#{remaining_timeout}s) too small, not waiting" }
|
|
384
|
+
return nil
|
|
385
|
+
end
|
|
298
386
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
387
|
+
# Block with timeout (longer than lease expiration to handle stale leases)
|
|
388
|
+
# But don't exceed the remaining timeout
|
|
389
|
+
timeout = [@lease_expiration_seconds + 2, remaining_timeout].min
|
|
390
|
+
@logger.debug { "Unable to acquire #{permit_count} permits, waiting for signals (timeout: #{timeout}s)" }
|
|
391
|
+
end
|
|
303
392
|
|
|
304
393
|
with_redis { |redis| redis.blpop("#{@namespace}:waiting_queue", timeout: timeout.to_f) }
|
|
305
394
|
|
|
306
395
|
# Try to acquire after any signal or timeout
|
|
307
|
-
lease_key = attempt_lease_acquisition(
|
|
396
|
+
lease_key = attempt_lease_acquisition(permit_count)
|
|
308
397
|
if lease_key
|
|
309
398
|
return lease_key
|
|
310
399
|
end
|
|
311
400
|
|
|
312
|
-
# If still can't acquire, return nil to continue the loop
|
|
313
|
-
@logger.debug "
|
|
401
|
+
# If still can't acquire, return nil to continue the loop
|
|
402
|
+
@logger.debug { "Still unable to acquire #{permit_count} permits after signal/timeout, continuing to wait" }
|
|
314
403
|
nil
|
|
315
404
|
end
|
|
316
405
|
|
|
317
|
-
def attempt_lease_acquisition(
|
|
406
|
+
def attempt_lease_acquisition(permit_count)
|
|
318
407
|
lease_id = generate_lease_id
|
|
319
408
|
lease_key = "#{@namespace}:leases:#{lease_id}"
|
|
320
409
|
lease_set_key = "#{@namespace}:lease_set"
|
|
@@ -325,7 +414,7 @@ module CountingSemaphore
|
|
|
325
414
|
keys: [lease_key, lease_set_key],
|
|
326
415
|
argv: [
|
|
327
416
|
@capacity.to_s,
|
|
328
|
-
|
|
417
|
+
permit_count.to_s,
|
|
329
418
|
@lease_expiration_seconds.to_s
|
|
330
419
|
]
|
|
331
420
|
)
|
|
@@ -335,15 +424,15 @@ module CountingSemaphore
|
|
|
335
424
|
if success == 1
|
|
336
425
|
# Extract just the lease ID from the full key for return value
|
|
337
426
|
lease_id = full_lease_key.split(":").last
|
|
338
|
-
@logger.debug "
|
|
427
|
+
@logger.debug { "Acquired lease #{lease_id}, current usage: #{current_usage}/#{@capacity}" }
|
|
339
428
|
lease_id
|
|
340
429
|
else
|
|
341
|
-
@logger.debug "
|
|
430
|
+
@logger.debug { "No capacity available, current usage: #{current_usage}/#{@capacity}" }
|
|
342
431
|
nil
|
|
343
432
|
end
|
|
344
433
|
end
|
|
345
434
|
|
|
346
|
-
def release_lease(lease_key,
|
|
435
|
+
def release_lease(lease_key, permit_count)
|
|
347
436
|
return if lease_key.nil?
|
|
348
437
|
|
|
349
438
|
full_lease_key = "#{@namespace}:leases:#{lease_key}"
|
|
@@ -355,12 +444,12 @@ module CountingSemaphore
|
|
|
355
444
|
:release_lease,
|
|
356
445
|
keys: [full_lease_key, queue_key, lease_set_key],
|
|
357
446
|
argv: [
|
|
358
|
-
|
|
447
|
+
permit_count.to_s,
|
|
359
448
|
(@capacity * 2).to_s
|
|
360
449
|
]
|
|
361
450
|
)
|
|
362
451
|
|
|
363
|
-
@logger.debug "
|
|
452
|
+
@logger.debug { "Returned #{permit_count} leased permits (lease: #{lease_key}) and signaled waiting clients" }
|
|
364
453
|
end
|
|
365
454
|
|
|
366
455
|
def get_current_usage
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CountingSemaphore
|
|
4
|
+
# Module providing backwards-compatible with_lease method
|
|
5
|
+
# Requires the including class to implement: acquire, release, capacity
|
|
6
|
+
module WithLeaseSupport
|
|
7
|
+
# Acquire a lease for the specified number of permits and execute the block.
|
|
8
|
+
# Blocks until sufficient resources are available.
|
|
9
|
+
# Kept for backwards compatibility - wraps acquire/release.
|
|
10
|
+
#
|
|
11
|
+
# @param permit_count [Integer] Number of permits to acquire (default: 1)
|
|
12
|
+
# @param timeout [Numeric] Maximum time in seconds to wait for lease acquisition (default: 30).
|
|
13
|
+
# For Redis-backed semaphores, the timeout value will be rounded up to the nearest whole second
|
|
14
|
+
# due to Redis BLPOP limitations.
|
|
15
|
+
# @yield [lease] The block to execute while holding the lease
|
|
16
|
+
# @yieldparam lease [CountingSemaphore::Lease, nil] The lease object (nil if permit_count is 0)
|
|
17
|
+
# @return The result of the block
|
|
18
|
+
# @raise [ArgumentError] if permit_count is negative or exceeds the semaphore capacity
|
|
19
|
+
# @raise [CountingSemaphore::LeaseTimeout] if lease cannot be acquired within timeout
|
|
20
|
+
def with_lease(permit_count = 1, timeout: 30)
|
|
21
|
+
permit_count = permit_count.to_i
|
|
22
|
+
raise ArgumentError, "Permit count must be non-negative, got #{permit_count}" if permit_count < 0
|
|
23
|
+
if permit_count > capacity
|
|
24
|
+
raise ArgumentError, "Cannot lease #{permit_count} permits as capacity is only #{capacity}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Handle zero permits case - no waiting needed
|
|
28
|
+
return yield(nil) if permit_count.zero?
|
|
29
|
+
|
|
30
|
+
# Use try_acquire with timeout
|
|
31
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
32
|
+
lease = nil
|
|
33
|
+
|
|
34
|
+
loop do
|
|
35
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
36
|
+
if elapsed_time >= timeout
|
|
37
|
+
raise CountingSemaphore::LeaseTimeout.new(permit_count, timeout, self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
remaining_timeout = timeout - elapsed_time
|
|
41
|
+
lease = try_acquire(permit_count, timeout: remaining_timeout)
|
|
42
|
+
break if lease
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
yield(lease)
|
|
47
|
+
ensure
|
|
48
|
+
release(lease) if lease
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get the current number of permits currently acquired.
|
|
53
|
+
# Kept for backwards compatibility.
|
|
54
|
+
#
|
|
55
|
+
# @return [Integer] Number of permits currently in use
|
|
56
|
+
def currently_leased
|
|
57
|
+
capacity - available_permits
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/counting_semaphore.rb
CHANGED
|
@@ -1,19 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require_relative "counting_semaphore/version"
|
|
2
4
|
|
|
3
5
|
module CountingSemaphore
|
|
4
|
-
#
|
|
6
|
+
# Represents an acquired lease on a semaphore.
|
|
7
|
+
# Must be passed to release() to return the permits.
|
|
8
|
+
Lease = Struct.new(:semaphore, :id, :permits, keyword_init: true) do
|
|
9
|
+
# Returns a human-readable representation of the lease
|
|
10
|
+
#
|
|
11
|
+
# @return [String]
|
|
12
|
+
def to_s
|
|
13
|
+
"Lease(#{permits} permits, id: #{id})"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns detailed inspection string
|
|
17
|
+
#
|
|
18
|
+
# @return [String]
|
|
19
|
+
def inspect
|
|
20
|
+
"#<CountingSemaphore::Lease permits=#{permits} id=#{id.inspect}>"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Custom exception raised when a semaphore lease cannot be acquired within the specified timeout.
|
|
25
|
+
# Contains information about the failed acquisition attempt including the semaphore instance,
|
|
26
|
+
# number of permits requested, and the timeout duration.
|
|
5
27
|
class LeaseTimeout < StandardError
|
|
6
|
-
|
|
28
|
+
# @return [CountingSemaphore::LocalSemaphore, CountingSemaphore::RedisSemaphore, nil] The semaphore that timed out
|
|
29
|
+
# @return [Integer] The number of permits that were requested
|
|
30
|
+
# @return [Numeric] The timeout duration in seconds
|
|
31
|
+
attr_reader :semaphore, :permit_count, :timeout_seconds
|
|
32
|
+
|
|
33
|
+
# For backwards compatibility, also provide token_count as an alias
|
|
34
|
+
alias_method :token_count, :permit_count
|
|
7
35
|
|
|
8
|
-
|
|
9
|
-
|
|
36
|
+
# Creates a new LeaseTimeout exception.
|
|
37
|
+
#
|
|
38
|
+
# @param permit_count [Integer] Number of permits that were requested
|
|
39
|
+
# @param timeout_seconds [Numeric] The timeout duration that was exceeded
|
|
40
|
+
# @param semaphore [CountingSemaphore::LocalSemaphore, CountingSemaphore::RedisSemaphore, nil] The semaphore instance (optional)
|
|
41
|
+
def initialize(permit_count, timeout_seconds, semaphore = nil)
|
|
42
|
+
@permit_count = permit_count
|
|
10
43
|
@timeout_seconds = timeout_seconds
|
|
11
44
|
@semaphore = semaphore
|
|
12
|
-
super("Failed to acquire #{
|
|
45
|
+
super("Failed to acquire #{permit_count} permits within #{timeout_seconds} seconds")
|
|
13
46
|
end
|
|
14
47
|
end
|
|
15
48
|
|
|
16
49
|
autoload :LocalSemaphore, "counting_semaphore/local_semaphore"
|
|
17
50
|
autoload :RedisSemaphore, "counting_semaphore/redis_semaphore"
|
|
18
51
|
autoload :NullLogger, "counting_semaphore/null_logger"
|
|
52
|
+
autoload :WithLeaseSupport, "counting_semaphore/with_lease_support"
|
|
19
53
|
end
|