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.
- checksums.yaml +7 -0
- data/.github/workflows/lint.yml +23 -0
- data/.github/workflows/test.yml +34 -0
- data/.gitignore +56 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +4 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +76 -0
- data/LICENSE +21 -0
- data/README.md +88 -0
- data/Rakefile +12 -0
- data/lib/counting_semaphore/local_semaphore.rb +94 -0
- data/lib/counting_semaphore/null_logger.rb +32 -0
- data/lib/counting_semaphore/redis_semaphore.rb +381 -0
- data/lib/counting_semaphore/shared_semaphore.rb +381 -0
- data/lib/counting_semaphore/version.rb +3 -0
- data/lib/counting_semaphore.rb +19 -0
- data/test/counting_semaphore/local_semaphore_test.rb +304 -0
- data/test/counting_semaphore/redis_semaphore_test.rb +486 -0
- data/test/test_helper.rb +10 -0
- metadata +134 -0
|
@@ -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
|
data/test/test_helper.rb
ADDED
|
@@ -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: []
|