counting_semaphore 0.1.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.
@@ -0,0 +1,381 @@
1
+ # A distributed counting semaphore that allows up to N concurrent operations across multiple processes.
2
+ # Uses Redis for coordination and automatically handles lease expiration for crashed processes.
3
+ # Uses Redis Lua scripts for atomic operations to prevent race conditions.
4
+ require "digest"
5
+ require "securerandom"
6
+
7
+ module CountingSemaphore
8
+ class RedisSemaphore
9
+ LEASE_EXPIRATION_SECONDS = 5
10
+
11
+ # Lua script for atomic lease acquisition
12
+ # Returns: [success, lease_key, current_usage]
13
+ # success: 1 if lease was acquired, 0 if no capacity
14
+ # lease_key: the key of the acquired lease (if successful)
15
+ # current_usage: current usage count after operation
16
+ GET_LEASE_SCRIPT = <<~LUA
17
+ local lease_key = KEYS[1]
18
+ local lease_set_key = KEYS[2]
19
+ local capacity = tonumber(ARGV[1])
20
+ local token_count = tonumber(ARGV[2])
21
+ local expiration_seconds = tonumber(ARGV[3])
22
+
23
+ -- Get all active leases from the set and calculate current usage
24
+ local lease_keys = redis.call('SMEMBERS', lease_set_key)
25
+ local current_usage = 0
26
+ local valid_leases = {}
27
+
28
+ for i, key in ipairs(lease_keys) do
29
+ local tokens = redis.call('GET', key)
30
+ if tokens then
31
+ local tokens_from_lease = tonumber(tokens)
32
+ if tokens_from_lease then
33
+ current_usage = current_usage + tokens_from_lease
34
+ table.insert(valid_leases, key)
35
+ else
36
+ -- Remove lease with invalid token count
37
+ redis.call('DEL', key)
38
+ redis.call('SREM', lease_set_key, key)
39
+ end
40
+ else
41
+ -- Lease key doesn't exist, remove from set
42
+ redis.call('SREM', lease_set_key, key)
43
+ end
44
+ end
45
+
46
+ -- Check if we have capacity
47
+ local available = capacity - current_usage
48
+ if available >= token_count then
49
+ -- Set lease with TTL (value is just the token count)
50
+ redis.call('SETEX', lease_key, expiration_seconds, token_count)
51
+ -- Add lease key to the set
52
+ redis.call('SADD', lease_set_key, lease_key)
53
+ -- Set TTL on the set (4x the lease TTL to ensure cleanup)
54
+ redis.call('EXPIRE', lease_set_key, expiration_seconds * 4)
55
+
56
+ return {1, lease_key, current_usage + token_count}
57
+ else
58
+ return {0, '', current_usage}
59
+ end
60
+ LUA
61
+
62
+ # Lua script for getting current usage
63
+ # Returns: current_usage (integer)
64
+ GET_USAGE_SCRIPT = <<~LUA
65
+ local lease_set_key = KEYS[1]
66
+ local expiration_seconds = tonumber(ARGV[1])
67
+
68
+ -- Get all active leases from the set and calculate current usage
69
+ local lease_keys = redis.call('SMEMBERS', lease_set_key)
70
+ local current_usage = 0
71
+ local has_valid_leases = false
72
+
73
+ for i, lease_key in ipairs(lease_keys) do
74
+ local tokens = redis.call('GET', lease_key)
75
+ if tokens then
76
+ local tokens_from_lease = tonumber(tokens)
77
+ if tokens_from_lease then
78
+ current_usage = current_usage + tokens_from_lease
79
+ has_valid_leases = true
80
+ else
81
+ -- Remove lease with invalid token count
82
+ redis.call('DEL', lease_key)
83
+ redis.call('SREM', lease_set_key, lease_key)
84
+ end
85
+ else
86
+ -- Lease key doesn't exist, remove from set
87
+ redis.call('SREM', lease_set_key, lease_key)
88
+ end
89
+ end
90
+
91
+ -- Refresh TTL on the set if there are valid leases (4x the lease TTL)
92
+ if has_valid_leases then
93
+ redis.call('EXPIRE', lease_set_key, expiration_seconds * 4)
94
+ end
95
+
96
+ return current_usage
97
+ LUA
98
+
99
+ # Lua script for atomic lease release and signal
100
+ # Returns: 1 (success)
101
+ RELEASE_LEASE_SCRIPT = <<~LUA
102
+ local lease_key = KEYS[1]
103
+ local queue_key = KEYS[2]
104
+ local lease_set_key = KEYS[3]
105
+ local token_count = tonumber(ARGV[1])
106
+ local max_signals = tonumber(ARGV[2])
107
+
108
+ -- Remove the lease
109
+ redis.call('DEL', lease_key)
110
+ -- Remove from the lease set
111
+ redis.call('SREM', lease_set_key, lease_key)
112
+
113
+ -- Signal waiting clients about the released tokens
114
+ redis.call('LPUSH', queue_key, 'tokens:' .. token_count)
115
+
116
+ -- Trim queue to prevent indefinite growth (atomic)
117
+ redis.call('LTRIM', queue_key, 0, max_signals - 1)
118
+
119
+ return 1
120
+ LUA
121
+
122
+ # Precomputed script SHAs
123
+ GET_LEASE_SCRIPT_SHA = Digest::SHA1.hexdigest(GET_LEASE_SCRIPT)
124
+ GET_USAGE_SCRIPT_SHA = Digest::SHA1.hexdigest(GET_USAGE_SCRIPT)
125
+ RELEASE_LEASE_SCRIPT_SHA = Digest::SHA1.hexdigest(RELEASE_LEASE_SCRIPT)
126
+
127
+ # @return [Integer]
128
+ attr_reader :capacity
129
+
130
+ # Initialize the semaphore with a maximum capacity and required namespace.
131
+ #
132
+ # @param capacity [Integer] Maximum number of concurrent operations allowed
133
+ # @param namespace [String] Required namespace for Redis keys
134
+ # @param redis [Redis, ConnectionPool] Optional Redis client or connection pool (defaults to new Redis instance)
135
+ # @param logger [Logger] the logger
136
+ # @raise [ArgumentError] if capacity is not positive
137
+ def initialize(capacity, namespace, redis: nil, logger: CountingSemaphore::NullLogger, lease_expiration_seconds: LEASE_EXPIRATION_SECONDS)
138
+ raise ArgumentError, "Capacity must be positive, got #{capacity}" unless capacity > 0
139
+
140
+ # Require Redis only when SharedSemaphore is used
141
+ require "redis" unless defined?(Redis)
142
+
143
+ @capacity = capacity
144
+ @redis_connection_pool = wrap_redis_client_with_pool(redis || Redis.new)
145
+ @namespace = namespace
146
+ @lease_expiration_seconds = lease_expiration_seconds
147
+ @logger = logger
148
+
149
+ # Scripts are precomputed and will be loaded on-demand if needed
150
+ end
151
+
152
+ # Null pool for bare Redis connections that don't need connection pooling
153
+ class NullPool
154
+ def initialize(redis_connection)
155
+ @redis_connection = redis_connection
156
+ end
157
+
158
+ def with(&block)
159
+ block.call(@redis_connection)
160
+ end
161
+ end
162
+
163
+ # Acquire a lease for the specified number of tokens and execute the block.
164
+ # Blocks until sufficient resources are available.
165
+ #
166
+ # @param token_count [Integer] Number of tokens to acquire
167
+ # @param timeout_seconds [Integer] Maximum time to wait for lease acquisition (default: 30 seconds)
168
+ # @yield The block to execute while holding the lease
169
+ # @return The result of the block
170
+ # @raise [ArgumentError] if token_count is negative or exceeds the semaphore capacity
171
+ # @raise [LeaseTimeout] if lease cannot be acquired within timeout
172
+ def with_lease(token_count = 1, timeout_seconds: 30)
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}"
176
+ end
177
+
178
+ # Handle zero tokens case - no Redis coordination needed
179
+ return yield if token_count.zero?
180
+
181
+ lease_key = acquire_lease(token_count, timeout_seconds: timeout_seconds)
182
+ begin
183
+ @logger.debug "🚦Leased #{token_count} tokens with lease #{lease_key}"
184
+ yield
185
+ ensure
186
+ release_lease(lease_key, token_count)
187
+ end
188
+ end
189
+
190
+ # Get the current number of tokens currently leased
191
+ #
192
+ # @return [Integer] Number of tokens currently in use
193
+ def currently_leased
194
+ get_current_usage
195
+ end
196
+
197
+ # Get current usage and active leases for debugging
198
+ def debug_info
199
+ usage = get_current_usage
200
+ lease_set_key = "#{@namespace}:lease_set"
201
+ lease_keys = with_redis { |redis| redis.smembers(lease_set_key) }
202
+ active_leases = []
203
+
204
+ lease_keys.each do |lease_key|
205
+ tokens = with_redis { |redis| redis.get(lease_key) }
206
+ next unless tokens
207
+
208
+ active_leases << {
209
+ key: lease_key,
210
+ tokens: tokens.to_i
211
+ }
212
+ end
213
+
214
+ {
215
+ usage: usage,
216
+ capacity: @capacity,
217
+ available: @capacity - usage,
218
+ active_leases: active_leases
219
+ }
220
+ end
221
+
222
+ private
223
+
224
+ # Wraps a Redis client to support both ConnectionPool and bare Redis connections
225
+ # @param redis [Redis, ConnectionPool] The Redis client or connection pool
226
+ # @return [Object] A wrapper that supports the `with` method
227
+ def wrap_redis_client_with_pool(redis)
228
+ # If it's already a ConnectionPool, return it as-is
229
+ return redis if redis.respond_to?(:with)
230
+
231
+ # For bare Redis connections, wrap in a NullPool
232
+ NullPool.new(redis)
233
+ end
234
+
235
+ # Executes a block with a Redis connection from the pool
236
+ # @yield [redis] The Redis connection
237
+ # @return The result of the block
238
+ def with_redis(&block)
239
+ @redis_connection_pool.with(&block)
240
+ end
241
+
242
+ # Executes a Redis script with automatic fallback to EVAL on NOSCRIPT error
243
+ # @param script_type [Symbol] The type of script (:get_lease, :release_lease, :get_usage)
244
+ # @param keys [Array] Keys for the script
245
+ # @param argv [Array] Arguments for the script
246
+ # @return The result of the script execution
247
+ def execute_script(script_type, keys: [], argv: [])
248
+ script_sha, script_body = case script_type
249
+ when :get_lease then [GET_LEASE_SCRIPT_SHA, GET_LEASE_SCRIPT]
250
+ when :release_lease then [RELEASE_LEASE_SCRIPT_SHA, RELEASE_LEASE_SCRIPT]
251
+ when :get_usage then [GET_USAGE_SCRIPT_SHA, GET_USAGE_SCRIPT]
252
+ else raise ArgumentError, "Unknown script type: #{script_type}"
253
+ end
254
+
255
+ with_redis do |redis|
256
+ redis.evalsha(script_sha, keys: keys, argv: argv)
257
+ end
258
+ rescue Redis::CommandError => e
259
+ if e.message.include?("NOSCRIPT")
260
+ @logger.debug "🚦Script not found, using EVAL: #{e.message}"
261
+ # Fall back to EVAL with the script body
262
+ with_redis do |redis|
263
+ redis.eval(script_body, keys: keys, argv: argv)
264
+ end
265
+ else
266
+ raise
267
+ end
268
+ end
269
+
270
+ def acquire_lease(token_count, timeout_seconds: 30)
271
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
272
+
273
+ loop do
274
+ # Check if we've exceeded the timeout using monotonic time
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)
278
+ end
279
+
280
+ # Try optimistic acquisition first
281
+ lease_key = attempt_lease_acquisition(token_count)
282
+ return lease_key if lease_key
283
+
284
+ # If failed, wait for signals with timeout
285
+ lease_key = wait_for_tokens(token_count, timeout_seconds - elapsed_time)
286
+ return lease_key if lease_key
287
+ end
288
+ end
289
+
290
+ def wait_for_tokens(token_count, remaining_timeout)
291
+ # Ensure minimum timeout to prevent infinite blocking
292
+ # BLPOP with timeout 0 blocks forever, so we need at least a small positive timeout
293
+ minimum_timeout = 0.1
294
+ if remaining_timeout <= minimum_timeout
295
+ @logger.debug "🚦Remaining timeout (#{remaining_timeout}s) too small, not waiting"
296
+ return nil
297
+ end
298
+
299
+ # Block with timeout (longer than lease expiration to handle stale leases)
300
+ # But don't exceed the remaining timeout
301
+ timeout = [@lease_expiration_seconds + 2, remaining_timeout].min
302
+ @logger.debug "🚦Unable to lease #{token_count}, waiting for signals (timeout: #{timeout}s)"
303
+
304
+ with_redis { |redis| redis.blpop("#{@namespace}:waiting_queue", timeout: timeout.to_f) }
305
+
306
+ # Try to acquire after any signal or timeout
307
+ lease_key = attempt_lease_acquisition(token_count)
308
+ if lease_key
309
+ return lease_key
310
+ end
311
+
312
+ # If still can't acquire, return nil to continue the loop in acquire_lease
313
+ @logger.debug "🚦Still unable to lease #{token_count} after signal/timeout, continuing to wait"
314
+ nil
315
+ end
316
+
317
+ def attempt_lease_acquisition(token_count)
318
+ lease_id = generate_lease_id
319
+ lease_key = "#{@namespace}:leases:#{lease_id}"
320
+ lease_set_key = "#{@namespace}:lease_set"
321
+
322
+ # Use Lua script for atomic lease acquisition
323
+ result = execute_script(
324
+ :get_lease,
325
+ keys: [lease_key, lease_set_key],
326
+ argv: [
327
+ @capacity.to_s,
328
+ token_count.to_s,
329
+ @lease_expiration_seconds.to_s
330
+ ]
331
+ )
332
+
333
+ success, full_lease_key, current_usage = result
334
+
335
+ if success == 1
336
+ # Extract just the lease ID from the full key for return value
337
+ lease_id = full_lease_key.split(":").last
338
+ @logger.debug "🚦Acquired lease #{lease_id}, current usage: #{current_usage}/#{@capacity}"
339
+ lease_id
340
+ else
341
+ @logger.debug "🚦No capacity available, current usage: #{current_usage}/#{@capacity}"
342
+ nil
343
+ end
344
+ end
345
+
346
+ def release_lease(lease_key, token_count)
347
+ return if lease_key.nil?
348
+
349
+ full_lease_key = "#{@namespace}:leases:#{lease_key}"
350
+ queue_key = "#{@namespace}:waiting_queue"
351
+ lease_set_key = "#{@namespace}:lease_set"
352
+
353
+ # Use Lua script for atomic lease release and signal
354
+ execute_script(
355
+ :release_lease,
356
+ keys: [full_lease_key, queue_key, lease_set_key],
357
+ argv: [
358
+ token_count.to_s,
359
+ (@capacity * 2).to_s
360
+ ]
361
+ )
362
+
363
+ @logger.debug "🚦Returned #{token_count} leased tokens (lease: #{lease_key}) and signaled waiting clients"
364
+ end
365
+
366
+ def get_current_usage
367
+ lease_set_key = "#{@namespace}:lease_set"
368
+
369
+ # Use the dedicated usage script that calculates current usage
370
+ execute_script(
371
+ :get_usage,
372
+ keys: [lease_set_key],
373
+ argv: [@lease_expiration_seconds.to_s]
374
+ )
375
+ end
376
+
377
+ def generate_lease_id
378
+ SecureRandom.uuid
379
+ end
380
+ end
381
+ end