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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cffd9d409923265971a2d4b2994a22b5a0a84ce57552f90ef3b0ce4d3ddb0c3d
4
+ data.tar.gz: d8ffaf1fa6d13ece63fed8d75bcd687d00450f378e93c19f06a4669f8722a457
5
+ SHA512:
6
+ metadata.gz: 3c59f43dfdac58fbe4a2c660138a7f6a2d2a70ead0afa85d5acf666fd72018a064a814cf933251ea4f21858ffd6e94931a5baacf4fdca04384d2ff8c76149175
7
+ data.tar.gz: ffb43973632962da0194d463688e82d7125fcafef9e1546e9a89a180c5ed22670cbf4d10061fb13df5586342f4e81a7b2324c6631074aaa4e6d9f56f65103b82
@@ -0,0 +1,23 @@
1
+ name: Lint
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version-file: .ruby-version
20
+ bundler-cache: true
21
+
22
+ - name: Run linter
23
+ run: bundle exec standardrb
@@ -0,0 +1,34 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ services:
14
+ redis:
15
+ image: redis:7
16
+ ports:
17
+ - 6379:6379
18
+ options: >-
19
+ --health-cmd "redis-cli ping"
20
+ --health-interval 10s
21
+ --health-timeout 5s
22
+ --health-retries 5
23
+
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+
27
+ - name: Set up Ruby
28
+ uses: ruby/setup-ruby@v1
29
+ with:
30
+ ruby-version-file: .ruby-version
31
+ bundler-cache: true
32
+
33
+ - name: Run tests
34
+ run: bundle exec rake test
data/.gitignore ADDED
@@ -0,0 +1,56 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ # Used by dotenv library to load environment variables.
14
+ # .env
15
+
16
+ # Ignore Byebug command history file.
17
+ .byebug_history
18
+
19
+ ## Specific to RubyMotion:
20
+ .dat*
21
+ .repl_history
22
+ build/
23
+ *.bridgesupport
24
+ build-iPhoneOS/
25
+ build-iPhoneSimulator/
26
+
27
+ ## Specific to RubyMotion (use of CocoaPods):
28
+ #
29
+ # We recommend against adding the Pods directory to your .gitignore. However
30
+ # you should judge for yourself, the pros and cons are mentioned at:
31
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
32
+ #
33
+ # vendor/Pods/
34
+
35
+ ## Documentation cache and generated files:
36
+ /.yardoc/
37
+ /_yardoc/
38
+ /doc/
39
+ /rdoc/
40
+
41
+ ## Environment normalization:
42
+ /.bundle/
43
+ /vendor/bundle
44
+ /lib/bundler/man/
45
+
46
+ # for a library or gem, you might want to ignore these files since the code is
47
+ # intended to run in multiple environments; otherwise, check them in:
48
+ # Gemfile.lock
49
+ # .ruby-version
50
+ # .ruby-gemset
51
+
52
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
53
+ .rvmrc
54
+
55
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
+ # .rubocop-https?--*
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.2
data/AGENTS.md ADDED
@@ -0,0 +1,4 @@
1
+ * Try to observe Rails conventions even if this is not a Rails application
2
+ * Place your temporary scripts in scripts/ if you generate them
3
+ * After modifying a file, run standardrb linter on it to auto-format, observe linter requirements
4
+ * Add frozen string literal magic comment to any Ruby files you touch or create
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in counting_semaphore.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,76 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ counting_semaphore (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ ast (2.4.3)
10
+ connection_pool (2.5.4)
11
+ json (2.15.1)
12
+ language_server-protocol (3.17.0.5)
13
+ lint_roller (1.1.0)
14
+ minitest (5.26.0)
15
+ parallel (1.27.0)
16
+ parser (3.3.9.0)
17
+ ast (~> 2.4.1)
18
+ racc
19
+ prism (1.6.0)
20
+ racc (1.8.1)
21
+ rainbow (3.1.1)
22
+ rake (13.3.0)
23
+ redis (5.4.1)
24
+ redis-client (>= 0.22.0)
25
+ redis-client (0.26.1)
26
+ connection_pool
27
+ regexp_parser (2.11.3)
28
+ rubocop (1.80.2)
29
+ json (~> 2.3)
30
+ language_server-protocol (~> 3.17.0.2)
31
+ lint_roller (~> 1.1.0)
32
+ parallel (~> 1.10)
33
+ parser (>= 3.3.0.2)
34
+ rainbow (>= 2.2.2, < 4.0)
35
+ regexp_parser (>= 2.9.3, < 3.0)
36
+ rubocop-ast (>= 1.46.0, < 2.0)
37
+ ruby-progressbar (~> 1.7)
38
+ unicode-display_width (>= 2.4.0, < 4.0)
39
+ rubocop-ast (1.47.1)
40
+ parser (>= 3.3.7.2)
41
+ prism (~> 1.4)
42
+ rubocop-performance (1.25.0)
43
+ lint_roller (~> 1.1)
44
+ rubocop (>= 1.75.0, < 2.0)
45
+ rubocop-ast (>= 1.38.0, < 2.0)
46
+ ruby-progressbar (1.13.0)
47
+ standard (1.51.1)
48
+ language_server-protocol (~> 3.17.0.2)
49
+ lint_roller (~> 1.0)
50
+ rubocop (~> 1.80.2)
51
+ standard-custom (~> 1.0.0)
52
+ standard-performance (~> 1.8)
53
+ standard-custom (1.0.2)
54
+ lint_roller (~> 1.0)
55
+ rubocop (~> 1.50)
56
+ standard-performance (1.8.0)
57
+ lint_roller (~> 1.1)
58
+ rubocop-performance (~> 1.25.0)
59
+ unicode-display_width (3.2.0)
60
+ unicode-emoji (~> 4.1)
61
+ unicode-emoji (4.1.0)
62
+
63
+ PLATFORMS
64
+ arm64-darwin-24
65
+ ruby
66
+
67
+ DEPENDENCIES
68
+ connection_pool (~> 2.4)
69
+ counting_semaphore!
70
+ minitest (~> 5.0)
71
+ rake (~> 13.0)
72
+ redis (~> 5.0)
73
+ standard (>= 1.35.1)
74
+
75
+ BUNDLED WITH
76
+ 2.6.9
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Julik Tarkhanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # counting_semaphore
2
+
3
+ A counting semaphore implementation for Ruby with local and distributed (Redis) variants.
4
+
5
+ > [!TIP]
6
+ > This gem was created for [Cora,](https://cora.computer/)
7
+ > your personal e-mail assistant.
8
+ > Send them some love for allowing me to share it.
9
+
10
+ ## What is it for?
11
+
12
+ When you have a metered and limited resource that only supports a certain number of simultaneous operations you need a [semaphore](https://en.wikipedia.org/wiki/Semaphore_(programming)) primitive. In Ruby, a semaphore usually controls access to "one whole resource":
13
+
14
+ ```ruby
15
+ sem = Semaphore.new
16
+ sem.with_lease do
17
+ # Critical section where you hold access to the resource
18
+ end
19
+ ```
20
+
21
+ This is well covered - for example - by POSIX semaphores if you are within one machine, or by the venerable [redis-semaphore](https://github.com/dv/redis-semaphore)
22
+
23
+ The problem comes if you need to hold access to a certain _amount_ of a resource. For example, you know that you are doing 5 expensive operations in bulk, and you know that your entire application can only be doing 20 in total - governed by the API access limits. For that, you need a [counting semaphore](https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/Semaphore.html#acquire-instance_method) - such a semaphore is provided by [concurrent-ruby](https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/Semaphore.html#acquire-instance_method) for example. It allows you to acquire a certain number of _permits_ and then release them.
24
+
25
+ This library does the same and also has a simple `LocalSemaphore` which can be used across threads or fibers. This allows for coordination if you are only running one process/Ractor. It is thread-safe and fairly simple in operation:
26
+
27
+ ```ruby
28
+ require "counting_semaphore"
29
+
30
+ # Create a semaphore that allows up to 10 concurrent operations
31
+ semaphore = CountingSemaphore::LocalSemaphore.new(10)
32
+
33
+ # Do an operation that occupies 2 slots
34
+ semaphore.with_lease(2) do
35
+ # This block can only run when 2 tokens are available
36
+ # Do your work here
37
+ puts "Doing work that requires 2 tokens"
38
+ end
39
+ ```
40
+
41
+ However, we also include a Redis based _shared counting semaphore_ which you can use for resource access control across processes and across machines - provided they have access to a shared Redis server. The semaphore is identified by a _namespace_ - think of it as the `id` of the semaphore. Leases are obtained and released using Redis blocking operations and Lua scripts (for atomicity):
42
+
43
+ ```ruby
44
+ require "counting_semaphore"
45
+ require "redis" # Redis is required for RedisSemaphore
46
+
47
+ # Create a Redis semaphore using Redis
48
+ # You can also pass your ConnectionPool instance.
49
+ redis = Redis.new
50
+ semaphore = CountingSemaphore::RedisSemaphore.new(10, "api_ratelimit", redis:)
51
+
52
+ # and then use it the same as the LocalSemaphore
53
+ semaphore.with_lease(3) do
54
+ # This block can only run when 3 tokens are available
55
+ # Works across multiple processes/machines
56
+ puts "Doing distributed work"
57
+ end
58
+ ```
59
+
60
+ ## Installation
61
+
62
+ Add this line to your application's Gemfile:
63
+
64
+ ```ruby
65
+ gem "counting_semaphore"
66
+ ```
67
+
68
+ And then execute:
69
+
70
+ $ bundle install
71
+
72
+ Or install it yourself as:
73
+
74
+ $ gem install counting_semaphore
75
+
76
+ There are no dependencies (but you need the `redis` gem for development - or you can feed a compatible object instead).
77
+
78
+ ## Development
79
+
80
+ Do a fresh checkout and run `bundle install`. Then run tests and linting using `bundle exec rake`.
81
+
82
+ ## Contributing
83
+
84
+ Bug reports and pull requests are welcome on GitHub at https://github.com/julik/counting_semaphore.
85
+
86
+ ## License
87
+
88
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+ require "standard/rake"
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ t.verbose = true
10
+ end
11
+
12
+ task default: [:test, :standard]
@@ -0,0 +1,94 @@
1
+ # A counting semaphore that allows up to N concurrent operations.
2
+ # When capacity is exceeded, operations block until resources become available.
3
+ module CountingSemaphore
4
+ class LocalSemaphore
5
+ SLEEP_WAIT_SECONDS = 0.25
6
+
7
+ # @return [Integer]
8
+ attr_reader :capacity
9
+
10
+ # Initialize the semaphore with a maximum capacity.
11
+ #
12
+ # @param capacity [Integer] Maximum number of concurrent operations allowed
13
+ # @param logger [Logger] the logger
14
+ # @raise [ArgumentError] if capacity is not positive
15
+ def initialize(capacity, logger: CountingSemaphore::NullLogger)
16
+ raise ArgumentError, "Capacity must be positive, got #{capacity}" unless capacity > 0
17
+ @capacity = capacity.to_i
18
+ @leased = 0
19
+ @mutex = Mutex.new
20
+ @condition = ConditionVariable.new
21
+ @logger = logger
22
+ end
23
+
24
+ # Acquire a lease for the specified number of tokens and execute the block.
25
+ # Blocks until sufficient resources are available.
26
+ #
27
+ # @param token_count [Integer] Number of tokens to acquire
28
+ # @param timeout_seconds [Integer] Maximum time to wait for lease acquisition (default: 30 seconds)
29
+ # @yield The block to execute while holding the lease
30
+ # @return The result of the block
31
+ # @raise [ArgumentError] if token_count is negative or exceeds the semaphore capacity
32
+ # @raise [CountingSemaphore::LeaseTimeout] if lease cannot be acquired within timeout
33
+ def with_lease(token_count_num = 1, timeout_seconds: 30)
34
+ token_count = token_count_num.to_i
35
+ raise ArgumentError, "Token count must be non-negative, got #{token_count}" if token_count < 0
36
+ if token_count > @capacity
37
+ raise ArgumentError, "Cannot lease #{token_count} slots as we only allow #{@capacity}"
38
+ end
39
+
40
+ # Handle zero tokens case - no waiting needed
41
+ return yield if token_count.zero?
42
+
43
+ did_accept = false
44
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+
46
+ loop do
47
+ # Check timeout
48
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
49
+ if elapsed_time >= timeout_seconds
50
+ raise CountingSemaphore::LeaseTimeout.new(token_count, timeout_seconds, self)
51
+ end
52
+
53
+ did_accept = @mutex.synchronize do
54
+ if (@capacity - @leased) >= token_count
55
+ @leased += token_count
56
+ true
57
+ else
58
+ false
59
+ end
60
+ end
61
+
62
+ if did_accept
63
+ @logger.debug { "Leased #{token_count} and now in use #{@leased}/#{@capacity}" }
64
+ return yield
65
+ end
66
+
67
+ @logger.debug { "Unable to lease #{token_count}, #{@leased}/#{@capacity} waiting" }
68
+
69
+ # Wait on condition variable with remaining timeout
70
+ remaining_timeout = timeout_seconds - elapsed_time
71
+ if remaining_timeout > 0
72
+ @mutex.synchronize do
73
+ @condition.wait(@mutex, remaining_timeout)
74
+ end
75
+ end
76
+ end
77
+ ensure
78
+ if did_accept
79
+ @logger.debug { "Returning #{token_count} leased slots" }
80
+ @mutex.synchronize do
81
+ @leased -= token_count
82
+ @condition.broadcast # Signal waiting threads
83
+ end
84
+ end
85
+ end
86
+
87
+ # Get the current number of tokens currently leased
88
+ #
89
+ # @return [Integer] Number of tokens currently in use
90
+ def currently_leased
91
+ @mutex.synchronize { @leased }
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,32 @@
1
+ module CountingSemaphore
2
+ # A null logger that discards all log messages
3
+ # Provides the same interface as a real logger but does nothing
4
+ # Only yields blocks when ENV["RUN_ALL_LOGGER_BLOCKS"] is set to "yes",
5
+ # which is useful in testing. Block form for Logger calls allows you
6
+ # to skip block evaluation if the Logger level is higher than your
7
+ # call, and thus bugs can nest in those Logger blocks. During
8
+ # testing it is helpful to excercise those blocks unconditionally.
9
+ module NullLogger
10
+ def debug(message = nil, &block)
11
+ yield if block_given? && ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
12
+ end
13
+
14
+ def info(message = nil, &block)
15
+ yield if block_given? && ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
16
+ end
17
+
18
+ def warn(message = nil, &block)
19
+ yield if block_given? && ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
20
+ end
21
+
22
+ def error(message = nil, &block)
23
+ yield if block_given? && ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
24
+ end
25
+
26
+ def fatal(message = nil, &block)
27
+ yield if block_given? && ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
28
+ end
29
+
30
+ extend self
31
+ end
32
+ end