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,486 @@
1
+ require "test_helper"
2
+ require "counting_semaphore"
3
+ require "redis"
4
+
5
+ class RedisSemaphoreTest < Minitest::Test
6
+ REDIS_DB = 2
7
+ TIMEOUT_SECONDS = 3
8
+
9
+ # Wrapper to ensure tests complete within 3 seconds
10
+ def self.test_with_timeout(description, &blk)
11
+ define_method("test_#{description}".gsub(/\s+/, "_")) do
12
+ Timeout.timeout(3) do
13
+ instance_exec(&blk)
14
+ end
15
+ rescue Timeout::Error
16
+ flunk "Test timed out after 3 seconds"
17
+ end
18
+ end
19
+
20
+ def test_initializes_with_correct_capacity
21
+ semaphore = CountingSemaphore::RedisSemaphore.new(5, "test_namespace")
22
+ assert_equal 5, semaphore.instance_variable_get(:@capacity)
23
+ end
24
+
25
+ def test_capacity_attribute_returns_the_initialized_capacity
26
+ semaphore = CountingSemaphore::RedisSemaphore.new(10, "test_namespace")
27
+ assert_equal 10, semaphore.capacity
28
+ end
29
+
30
+ def test_capacity_attribute_is_immutable
31
+ semaphore = CountingSemaphore::RedisSemaphore.new(7, "test_namespace")
32
+ assert_equal 7, semaphore.capacity
33
+
34
+ # Verify that capacity cannot be modified directly
35
+ assert_raises(NoMethodError) do
36
+ semaphore.capacity = 5
37
+ end
38
+ end
39
+
40
+ def test_raises_error_for_negative_capacity
41
+ assert_raises(ArgumentError, "Capacity must be positive, got -3") do
42
+ CountingSemaphore::RedisSemaphore.new(-3, "test_namespace")
43
+ end
44
+ end
45
+
46
+ def test_raises_error_for_zero_capacity
47
+ assert_raises(ArgumentError, "Capacity must be positive, got 0") do
48
+ CountingSemaphore::RedisSemaphore.new(0, "test_namespace")
49
+ end
50
+ end
51
+
52
+ def test_raises_error_for_negative_token_count
53
+ semaphore = CountingSemaphore::RedisSemaphore.new(2, "test_namespace")
54
+
55
+ assert_raises(ArgumentError, "Token count must be non-negative, got -1") do
56
+ semaphore.with_lease(-1) do
57
+ "should not reach here"
58
+ end
59
+ end
60
+ end
61
+
62
+ def test_allows_zero_token_count
63
+ semaphore = CountingSemaphore::RedisSemaphore.new(2, "test_namespace")
64
+ result = nil
65
+
66
+ semaphore.with_lease(0) do
67
+ result = "success"
68
+ end
69
+
70
+ assert_equal "success", result
71
+ end
72
+
73
+ def test_supports_connection_pool_redis_client
74
+ # Create a mock ConnectionPool that responds to :with
75
+ connection_pool = Class.new do
76
+ def initialize(redis_connection)
77
+ @redis_connection = redis_connection
78
+ end
79
+
80
+ def with(&block)
81
+ block.call(@redis_connection)
82
+ end
83
+ end.new(Redis.new(db: REDIS_DB))
84
+
85
+ semaphore = CountingSemaphore::RedisSemaphore.new(2, "test_namespace", redis: connection_pool)
86
+ result = nil
87
+
88
+ semaphore.with_lease(1) do
89
+ result = "success"
90
+ end
91
+
92
+ assert_equal "success", result
93
+ end
94
+
95
+ def test_supports_bare_redis_connection
96
+ # Test with a bare Redis connection (should be wrapped automatically)
97
+ bare_redis = Redis.new(db: REDIS_DB)
98
+ semaphore = CountingSemaphore::RedisSemaphore.new(2, "test_namespace", redis: bare_redis)
99
+ result = nil
100
+
101
+ semaphore.with_lease(1) do
102
+ result = "success"
103
+ end
104
+
105
+ assert_equal "success", result
106
+ end
107
+
108
+ test_with_timeout "two clients with signaling using threads and condition variables" do
109
+ results = []
110
+ mutex = Mutex.new
111
+ client1_acquired_condition = ConditionVariable.new
112
+ release_condition = ConditionVariable.new
113
+
114
+ # Create separate semaphores with separate Redis connections but same namespace
115
+ shared_namespace = "test_semaphore_#{SecureRandom.uuid}"
116
+
117
+ # Client 1: Acquires 8 tokens, then waits for signal to release
118
+ client1_thread = Thread.new do
119
+ semaphore1 = CountingSemaphore::RedisSemaphore.new(
120
+ 10, # capacity
121
+ shared_namespace, # Same namespace so they can signal each other
122
+ redis: Redis.new(db: REDIS_DB),
123
+ lease_expiration_seconds: 1
124
+ )
125
+
126
+ semaphore1.with_lease(8) do
127
+ results << "client1_acquired_8_tokens"
128
+
129
+ # Signal that client1 has acquired the lease
130
+ mutex.synchronize do
131
+ results << "client1_signaling_acquisition"
132
+ client1_acquired_condition.signal
133
+ end
134
+
135
+ # Wait for signal to release the lease
136
+ mutex.synchronize do
137
+ results << "client1_waiting_for_release_signal"
138
+ release_condition.wait(mutex, 2) # Wait up to 2 seconds for release signal
139
+ results << "client1_releasing_8_tokens"
140
+ end
141
+ end
142
+ results << "client1_finished"
143
+ end
144
+
145
+ # Client 2: Waits for client1 to acquire, then attempts to acquire 5 tokens
146
+ client2_thread = Thread.new do
147
+ semaphore2 = CountingSemaphore::RedisSemaphore.new(
148
+ 10, # capacity
149
+ shared_namespace, # Same namespace so they can signal each other
150
+ redis: Redis.new(db: REDIS_DB),
151
+ lease_expiration_seconds: 1
152
+ )
153
+
154
+ # Wait for client1 to acquire the lease
155
+ mutex.synchronize do
156
+ results << "client2_waiting_for_client1_acquisition"
157
+ client1_acquired_condition.wait(mutex, 2) # Wait up to 2 seconds for client1 to acquire
158
+ results << "client2_attempting_5_tokens"
159
+ end
160
+
161
+ semaphore2.with_lease(5, timeout_seconds: 2) do
162
+ results << "client2_acquired_5_tokens"
163
+ sleep 0.05
164
+ results << "client2_releasing_5_tokens"
165
+ end
166
+ results << "client2_finished"
167
+ end
168
+
169
+ # Wait a bit for both clients to get into position
170
+ sleep 0.1
171
+
172
+ # Signal client1 to release the lease
173
+ mutex.synchronize do
174
+ results << "signaling_client1_to_release"
175
+ release_condition.signal
176
+ end
177
+
178
+ # Wait for both clients to complete
179
+ client1_thread.join
180
+ client2_thread.join
181
+
182
+ # Verify the sequence of events (order may vary due to timing)
183
+ assert_includes results, "client1_acquired_8_tokens"
184
+ assert_includes results, "client1_signaling_acquisition"
185
+ assert_includes results, "client1_waiting_for_release_signal"
186
+ assert_includes results, "client2_waiting_for_client1_acquisition"
187
+ assert_includes results, "client2_attempting_5_tokens"
188
+ assert_includes results, "signaling_client1_to_release"
189
+ assert_includes results, "client1_releasing_8_tokens"
190
+ assert_includes results, "client1_finished"
191
+ assert_includes results, "client2_acquired_5_tokens"
192
+ assert_includes results, "client2_releasing_5_tokens"
193
+ assert_includes results, "client2_finished"
194
+ end
195
+
196
+ test_with_timeout "many clients all fighting for a resource" do
197
+ capacity = 11
198
+ lease = 4
199
+ n_clients = 7
200
+ operations = Set.new
201
+
202
+ namespace = "test_semaphore_#{SecureRandom.uuid}"
203
+
204
+ mux = Mutex.new
205
+ threads = n_clients.times.map do |i|
206
+ Thread.new do
207
+ semaphore = CountingSemaphore::RedisSemaphore.new(
208
+ capacity,
209
+ namespace,
210
+ redis: Redis.new(db: REDIS_DB),
211
+ lease_expiration_seconds: 5
212
+ )
213
+ semaphore.with_lease(lease) do
214
+ mux.synchronize { operations << "op from client #{i}" }
215
+ end
216
+ end
217
+ end
218
+ threads.map(&:join)
219
+ assert_equal n_clients, operations.size
220
+ end
221
+
222
+ test_with_timeout "client timeout when semaphore is fully occupied" do
223
+ capacity = 5
224
+ namespace = "test_semaphore_#{SecureRandom.uuid}"
225
+ mutex = Mutex.new
226
+ condition = ConditionVariable.new
227
+ client1_acquired = false
228
+ client2_timeout_raised = false
229
+
230
+ # Client 1: Acquires all 5 tokens and waits for signal to release
231
+ client1_thread = Thread.new do
232
+ semaphore1 = CountingSemaphore::RedisSemaphore.new(
233
+ capacity,
234
+ namespace,
235
+ redis: Redis.new(db: REDIS_DB),
236
+ lease_expiration_seconds: 10
237
+ )
238
+
239
+ semaphore1.with_lease(capacity) do
240
+ mutex.synchronize do
241
+ client1_acquired = true
242
+ condition.signal # Signal that client1 has acquired all tokens
243
+ end
244
+
245
+ # Wait for signal to release the lease
246
+ mutex.synchronize do
247
+ condition.wait(mutex, 2) # Wait up to 2 seconds for release signal
248
+ end
249
+ end
250
+ end
251
+
252
+ # Client 2: Tries to acquire 1 token with short timeout (should fail)
253
+ client2_thread = Thread.new do
254
+ # Wait for client1 to acquire all tokens
255
+ mutex.synchronize do
256
+ condition.wait(mutex, 1) until client1_acquired
257
+ end
258
+
259
+ semaphore2 = CountingSemaphore::RedisSemaphore.new(
260
+ capacity,
261
+ namespace,
262
+ redis: Redis.new(db: REDIS_DB),
263
+ lease_expiration_seconds: 10
264
+ )
265
+
266
+ begin
267
+ semaphore2.with_lease(1, timeout_seconds: 0.5) do
268
+ # This should not execute
269
+ end
270
+ rescue CountingSemaphore::LeaseTimeout
271
+ client2_timeout_raised = true
272
+ end
273
+ end
274
+
275
+ # Wait for both clients to complete
276
+ client1_thread.join
277
+ client2_thread.join
278
+
279
+ # Verify that client2 timed out as expected
280
+ assert client2_timeout_raised, "Expected client2 to raise LeaseTimeout but it didn't"
281
+ end
282
+
283
+ def test_lease_timeout_includes_semaphore_reference
284
+ capacity = 5
285
+ namespace = "test_semaphore_#{SecureRandom.uuid}"
286
+ mutex = Mutex.new
287
+ condition = ConditionVariable.new
288
+ client1_acquired = false
289
+ exception = nil
290
+ semaphore2 = nil
291
+
292
+ # Client 1: Acquires all 5 tokens and waits for signal to release
293
+ client1_thread = Thread.new do
294
+ semaphore1 = CountingSemaphore::RedisSemaphore.new(
295
+ capacity,
296
+ namespace,
297
+ redis: Redis.new(db: REDIS_DB),
298
+ lease_expiration_seconds: 10
299
+ )
300
+
301
+ semaphore1.with_lease(capacity) do
302
+ mutex.synchronize do
303
+ client1_acquired = true
304
+ condition.signal # Signal that client1 has acquired all tokens
305
+ end
306
+
307
+ # Wait for signal to release the lease
308
+ mutex.synchronize do
309
+ condition.wait(mutex, 2) # Wait up to 2 seconds for release signal
310
+ end
311
+ end
312
+ end
313
+
314
+ # Client 2: Tries to acquire 1 token with short timeout (should fail)
315
+ client2_thread = Thread.new do
316
+ # Wait for client1 to acquire all tokens
317
+ mutex.synchronize do
318
+ condition.wait(mutex, 1) until client1_acquired
319
+ end
320
+
321
+ semaphore2 = CountingSemaphore::RedisSemaphore.new(
322
+ capacity,
323
+ namespace,
324
+ redis: Redis.new(db: REDIS_DB),
325
+ lease_expiration_seconds: 10
326
+ )
327
+
328
+ begin
329
+ semaphore2.with_lease(1, timeout_seconds: 0.5) do
330
+ # This should not execute
331
+ end
332
+ rescue CountingSemaphore::LeaseTimeout => e
333
+ exception = e
334
+ end
335
+ end
336
+
337
+ # Wait for both clients to complete
338
+ client1_thread.join
339
+ client2_thread.join
340
+
341
+ # Verify that the exception includes the semaphore reference
342
+ refute_nil exception
343
+ assert_equal semaphore2, exception.semaphore
344
+ assert_equal 1, exception.token_count
345
+ assert_equal 0.5, exception.timeout_seconds
346
+ end
347
+
348
+ def test_with_lease_uses_default_token_count_of_1
349
+ semaphore = CountingSemaphore::RedisSemaphore.new(2, "test_namespace")
350
+ result = nil
351
+
352
+ # Should work with default token count (1)
353
+ semaphore.with_lease do
354
+ result = "success"
355
+ end
356
+
357
+ assert_equal "success", result
358
+ end
359
+
360
+ def test_with_lease_default_blocks_when_capacity_exceeded
361
+ namespace = "test_semaphore_#{SecureRandom.uuid}"
362
+ mutex = Mutex.new
363
+ condition = ConditionVariable.new
364
+ client1_acquired = false
365
+ client2_timeout_raised = false
366
+
367
+ # Client 1: Acquires all tokens using default (1) and waits for signal to release
368
+ client1_thread = Thread.new do
369
+ semaphore1 = CountingSemaphore::RedisSemaphore.new(
370
+ 1, # capacity
371
+ namespace,
372
+ redis: Redis.new(db: REDIS_DB),
373
+ lease_expiration_seconds: 10
374
+ )
375
+
376
+ semaphore1.with_lease do # Uses default token count of 1
377
+ mutex.synchronize do
378
+ client1_acquired = true
379
+ condition.signal # Signal that client1 has acquired the token
380
+ end
381
+
382
+ # Wait for signal to release the lease
383
+ mutex.synchronize do
384
+ condition.wait(mutex, 2) # Wait up to 2 seconds for release signal
385
+ end
386
+ end
387
+ end
388
+
389
+ # Client 2: Tries to acquire 1 token with short timeout (should fail)
390
+ client2_thread = Thread.new do
391
+ # Wait for client1 to acquire the token
392
+ mutex.synchronize do
393
+ condition.wait(mutex, 1) until client1_acquired
394
+ end
395
+
396
+ semaphore2 = CountingSemaphore::RedisSemaphore.new(
397
+ 1, # capacity
398
+ namespace,
399
+ redis: Redis.new(db: REDIS_DB),
400
+ lease_expiration_seconds: 10
401
+ )
402
+
403
+ begin
404
+ semaphore2.with_lease(timeout_seconds: 0.5) do # Uses default token count of 1
405
+ # This should not execute
406
+ end
407
+ rescue CountingSemaphore::LeaseTimeout
408
+ client2_timeout_raised = true
409
+ end
410
+ end
411
+
412
+ # Wait for both clients to complete
413
+ client1_thread.join
414
+ client2_thread.join
415
+
416
+ # Verify that client2 timed out as expected
417
+ assert client2_timeout_raised, "Expected client2 to raise LeaseTimeout but it didn't"
418
+ end
419
+
420
+ def test_currently_leased_returns_zero_initially
421
+ semaphore = CountingSemaphore::RedisSemaphore.new(5, "test_namespace")
422
+ assert_equal 0, semaphore.currently_leased
423
+ end
424
+
425
+ def test_currently_leased_increases_during_lease
426
+ semaphore = CountingSemaphore::RedisSemaphore.new(5, "test_namespace")
427
+ usage_during_lease = nil
428
+
429
+ semaphore.with_lease(2) do
430
+ usage_during_lease = semaphore.currently_leased
431
+ end
432
+
433
+ assert_equal 2, usage_during_lease
434
+ assert_equal 0, semaphore.currently_leased
435
+ end
436
+
437
+ def test_currently_leased_returns_to_zero_after_lease
438
+ semaphore = CountingSemaphore::RedisSemaphore.new(3, "test_namespace")
439
+
440
+ semaphore.with_lease(2) do
441
+ assert_equal 2, semaphore.currently_leased
442
+ end
443
+
444
+ assert_equal 0, semaphore.currently_leased
445
+ end
446
+
447
+ def test_currently_leased_with_multiple_concurrent_leases
448
+ namespace = "test_semaphore_#{SecureRandom.uuid}"
449
+ semaphore = CountingSemaphore::RedisSemaphore.new(5, namespace)
450
+ usage_values = []
451
+ mutex = Mutex.new
452
+
453
+ # Start multiple threads that will hold leases
454
+ threads = []
455
+ 3.times do |i|
456
+ threads << Thread.new do
457
+ semaphore.with_lease(1) do
458
+ mutex.synchronize { usage_values << semaphore.currently_leased }
459
+ sleep(0.1) # Hold the lease briefly
460
+ end
461
+ end
462
+ end
463
+
464
+ threads.each(&:join)
465
+
466
+ # Should have seen usage values of 1, 2, and 3 (or some combination)
467
+ assert usage_values.any? { |usage| usage >= 1 }
468
+ assert usage_values.any? { |usage| usage <= 3 }
469
+ assert_equal 0, semaphore.currently_leased
470
+ end
471
+
472
+ def test_currently_leased_with_distributed_leases
473
+ namespace = "test_semaphore_#{SecureRandom.uuid}"
474
+ semaphore1 = CountingSemaphore::RedisSemaphore.new(5, namespace)
475
+ semaphore2 = CountingSemaphore::RedisSemaphore.new(5, namespace)
476
+
477
+ # Both semaphores should see the same usage since they share the namespace
478
+ semaphore1.with_lease(2) do
479
+ assert_equal 2, semaphore1.currently_leased
480
+ assert_equal 2, semaphore2.currently_leased
481
+ end
482
+
483
+ assert_equal 0, semaphore1.currently_leased
484
+ assert_equal 0, semaphore2.currently_leased
485
+ end
486
+ end
@@ -0,0 +1,10 @@
1
+ require "minitest/autorun"
2
+ require "timeout"
3
+ require "securerandom"
4
+
5
+ # Set environment variable to run all logger blocks in tests
6
+ # Block form for Logger calls allows you
7
+ # to skip block evaluation if the Logger level is higher than your
8
+ # call, and thus bugs can nest in those Logger blocks. During
9
+ # testing it is helpful to excercise those blocks unconditionally.
10
+ ENV["RUN_ALL_LOGGER_BLOCKS"] = "yes"
metadata ADDED
@@ -0,0 +1,134 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: counting_semaphore
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julik Tarkhanov
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-10-17 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: standard
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.35.1
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 1.35.1
54
+ - !ruby/object:Gem::Dependency
55
+ name: redis
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: connection_pool
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.4'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.4'
82
+ description: Provides both local (in-process) and shared (Redis-based) counting semaphores
83
+ for controlling concurrent access to resources
84
+ email:
85
+ - me@julik.nl
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - ".github/workflows/lint.yml"
91
+ - ".github/workflows/test.yml"
92
+ - ".gitignore"
93
+ - ".ruby-version"
94
+ - AGENTS.md
95
+ - Gemfile
96
+ - Gemfile.lock
97
+ - LICENSE
98
+ - README.md
99
+ - Rakefile
100
+ - lib/counting_semaphore.rb
101
+ - lib/counting_semaphore/local_semaphore.rb
102
+ - lib/counting_semaphore/null_logger.rb
103
+ - lib/counting_semaphore/redis_semaphore.rb
104
+ - lib/counting_semaphore/shared_semaphore.rb
105
+ - lib/counting_semaphore/version.rb
106
+ - test/counting_semaphore/local_semaphore_test.rb
107
+ - test/counting_semaphore/redis_semaphore_test.rb
108
+ - test/test_helper.rb
109
+ homepage: https://github.com/julik/counting_semaphore
110
+ licenses:
111
+ - MIT
112
+ metadata:
113
+ homepage_uri: https://github.com/julik/counting_semaphore
114
+ source_code_uri: https://github.com/julik/counting_semaphore
115
+ changelog_uri: https://github.com/julik/counting_semaphore/blob/main/CHANGELOG.md
116
+ rdoc_options: []
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: 2.7.0
124
+ required_rubygems_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0'
129
+ requirements: []
130
+ rubygems_version: 3.6.2
131
+ specification_version: 4
132
+ summary: A counting semaphore implementation for Ruby with local and distributed (Redis)
133
+ variants
134
+ test_files: []