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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cffd9d409923265971a2d4b2994a22b5a0a84ce57552f90ef3b0ce4d3ddb0c3d
4
- data.tar.gz: d8ffaf1fa6d13ece63fed8d75bcd687d00450f378e93c19f06a4669f8722a457
3
+ metadata.gz: f8aa18aa9fc4f48207c718855f024c62dc6034f8708cfb9ce6adf530502fddb2
4
+ data.tar.gz: 108b3734991093af91e790c347f3ae83a3ca187bfb647648745a7c7cc8d89f7d
5
5
  SHA512:
6
- metadata.gz: 3c59f43dfdac58fbe4a2c660138a7f6a2d2a70ead0afa85d5acf666fd72018a064a814cf933251ea4f21858ffd6e94931a5baacf4fdca04384d2ff8c76149175
7
- data.tar.gz: ffb43973632962da0194d463688e82d7125fcafef9e1546e9a89a180c5ed22670cbf4d10061fb13df5586342f4e81a7b2324c6631074aaa4e6d9f56f65103b82
6
+ metadata.gz: 43bf0f8f4ff7222f87d934ca2a26d154a33b44bd9639f9c8c1868322c0e6e92103a95f85e05ddc5f2756768ab6c8435fc82aebdf7ca6eaed8554a987693eb3f2
7
+ data.tar.gz: d365b7adf32b5462454d69f3358539bb8c16c8efbca2c9ec70abd51c4a7e302ec976615b296d4656598062cd9e4850f30bb935325f9db5ebcf8295a247aae7b7
data/README.md CHANGED
@@ -9,7 +9,7 @@ A counting semaphore implementation for Ruby with local and distributed (Redis)
9
9
 
10
10
  ## What is it for?
11
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":
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, most semaphores usually controls access "one whole resource":
13
13
 
14
14
  ```ruby
15
15
  sem = Semaphore.new
@@ -18,45 +18,191 @@ sem.with_lease do
18
18
  end
19
19
  ```
20
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)
21
+ This is well covered - for example - by POSIX semaphores if you are within one machine, and is known as a _binary semaphore_ (it is either "open" or "closed"). There are also _counting_ semaphores where you permit N of leases to be taken, which is available in the venerable [redis-semaphore](https://github.com/dv/redis-semaphore) gem.
22
22
 
23
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
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:
25
+ This library provides both a simple `LocalSemaphore` which can be used across threads or fibers, and a Redis-based `RedisSemaphore` for coordination across processes and machines. Both implement a Lease-based API compatible with concurrent-ruby's Semaphore.
26
+
27
+ ## Usage
28
+
29
+ ### Basic Usage with `with_lease`
30
+
31
+ The recommended way to use the semaphore is with the `with_lease` method, which provides automatic cleanup:
26
32
 
27
33
  ```ruby
28
- require "counting_semaphore"
34
+ require 'counting_semaphore'
29
35
 
30
- # Create a semaphore that allows up to 10 concurrent operations
36
+ # Create a local semaphore with capacity of 10
31
37
  semaphore = CountingSemaphore::LocalSemaphore.new(10)
32
38
 
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"
39
+ # Acquire 3 permits and automatically release on block exit
40
+ semaphore.with_lease(3, timeout_seconds: 10) do
41
+ puts "Holding 3 permits"
42
+ # Do your work here - permits are automatically released when the block exits
38
43
  end
39
44
  ```
40
45
 
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):
46
+ The block receives the lease object, which you can inspect:
42
47
 
43
48
  ```ruby
44
- require "counting_semaphore"
45
- require "redis" # Redis is required for RedisSemaphore
49
+ semaphore.with_lease(3) do |lease|
50
+ puts "Holding #{lease.permits} permits (ID: #{lease.id})"
51
+ # Automatic cleanup on block exit
52
+ end
53
+ ```
46
54
 
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:)
55
+ ### Distributed Semaphore with Redis
56
+
57
+ The Redis semaphore works identically but coordinates across processes and machines:
51
58
 
52
- # and then use it the same as the LocalSemaphore
59
+ ```ruby
60
+ require 'redis'
61
+
62
+ redis = Redis.new
63
+ semaphore = CountingSemaphore::RedisSemaphore.new(
64
+ 10, # capacity
65
+ "api_ratelimit", # namespace (unique identifier)
66
+ redis: redis,
67
+ lease_ttl_seconds: 60 # lease expires after 60 seconds
68
+ )
69
+
70
+ # Use it the same way - works across multiple processes
53
71
  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"
72
+ puts "Doing distributed work with 3 permits"
73
+ # Permits automatically released when done
74
+ end
75
+ ```
76
+
77
+ ### Checking Availability
78
+
79
+ You can query the current state of the semaphore:
80
+
81
+ ```ruby
82
+ puts "Available permits: #{semaphore.available_permits}"
83
+ puts "Capacity: #{semaphore.capacity}"
84
+ puts "Currently in use: #{semaphore.currently_leased}"
85
+ ```
86
+
87
+ ### Advanced: Manual Lease Control
88
+
89
+ For more control, you can manually acquire and release leases. This is useful when you can't use a block structure:
90
+
91
+ ```ruby
92
+ # Acquire permits (returns a Lease object)
93
+ lease = semaphore.acquire(2)
94
+
95
+ begin
96
+ # Do some work
97
+ puts "Working with 2 permits..."
98
+ ensure
99
+ # Always release the lease
100
+ semaphore.release(lease)
101
+ end
102
+ ```
103
+
104
+ #### Try Acquire with Timeout
105
+
106
+ ```ruby
107
+ # Try to acquire immediately (returns nil if not available)
108
+ lease = semaphore.try_acquire(1)
109
+ if lease
110
+ begin
111
+ puts "Got the permit!"
112
+ ensure
113
+ semaphore.release(lease)
114
+ end
115
+ else
116
+ puts "Could not acquire permit"
57
117
  end
118
+
119
+ # Try to acquire with timeout
120
+ lease = semaphore.try_acquire(2, 5.0) # Wait up to 5 seconds
121
+ if lease
122
+ begin
123
+ # Work with the permits
124
+ ensure
125
+ semaphore.release(lease)
126
+ end
127
+ end
128
+ ```
129
+
130
+ #### Drain All Available Permits
131
+
132
+ ```ruby
133
+ # Acquire all currently available permits
134
+ drained_lease = semaphore.drain_permits
135
+
136
+ if drained_lease
137
+ begin
138
+ puts "Drained #{drained_lease.permits} permits for exclusive access"
139
+ # Do exclusive work
140
+ ensure
141
+ semaphore.release(drained_lease)
142
+ end
143
+ end
144
+ ```
145
+
146
+ ### Key Benefits
147
+
148
+ 1. **Automatic Cleanup**: `with_lease` ensures permits are always released
149
+ 2. **Type Safety**: Lease objects ensure you can only release what you've acquired
150
+ 3. **Cross-Semaphore Protection**: Can't accidentally release a lease to the wrong semaphore
151
+ 4. **Distributed Coordination**: Redis semaphore works seamlessly across processes and machines
152
+ 5. **Lease Expiration**: Redis leases automatically expire to prevent deadlocks
153
+
154
+ ## Design Philosophy
155
+
156
+ This library aims for compatibility with [`Concurrent::Semaphore`](https://ruby-concurrency.github.io/concurrent-ruby/1.1.5/Concurrent/Semaphore.html) from the concurrent-ruby gem, but with a key difference to support both local and distributed implementations.
157
+
158
+ ### How It Works
159
+
160
+ The core difference from `Concurrent::Semaphore` is that **`acquire` returns a lease object** that must be passed to `release`, rather than using numeric permit counts for both operations:
161
+
162
+ ```ruby
163
+ # concurrent-ruby style
164
+ semaphore.acquire(2)
165
+ # ... work ...
166
+ semaphore.release(2) # Must remember the count!
167
+
168
+ # counting_semaphore style
169
+ lease = semaphore.acquire(2)
170
+ # ... work ...
171
+ semaphore.release(lease) # Lease knows its own count
58
172
  ```
59
173
 
174
+ #### Why Not 100% API Parity?
175
+
176
+ The `Concurrent::Semaphore` API where `acquire(n)` and `release(n)` use arbitrary counts works well for in-memory semaphores, but creates challenges for distributed Redis-based implementations:
177
+
178
+ 1. **Individual leases need TTLs**: In Redis, each lease must have an expiration to prevent deadlocks from crashed processes
179
+ 2. **Lease tracking is essential**: Distributed systems need unique identifiers for each acquired lease
180
+ 3. **Cross-process coordination**: Releasing "2 permits" doesn't map cleanly to "which 2 leases?" across processes
181
+ 4. **Ownership semantics**: The lease object makes it explicit what you acquired and what you're releasing
182
+
183
+ #### The Lease Object
184
+
185
+ A lease is a simple struct that contains:
186
+ - `semaphore` - reference to the semaphore it came from
187
+ - `id` - unique identifier (local counter for LocalSemaphore, Redis key for RedisSemaphore)
188
+ - `permits` - number of permits held
189
+
190
+ This design:
191
+ - **Prevents bugs**: Can't accidentally release the wrong amount or to the wrong semaphore
192
+ - **Works for both implementations**: LocalSemaphore and RedisSemaphore use the same API
193
+ - **Follows familiar patterns**: Similar to file handles, database connections, and other resource management
194
+ - **Maintains compatibility**: The `with_lease` block form works identically to concurrent-ruby's usage
195
+
196
+ #### Query Methods
197
+
198
+ The library provides the same query methods as `Concurrent::Semaphore`:
199
+
200
+ - `available_permits` - returns the number of permits currently available
201
+ - `capacity` - returns the total capacity of the semaphore
202
+ - `currently_leased` - returns the number of permits currently in use
203
+
204
+ Additionally, `drain_permits` returns a lease object (or nil) instead of an integer, maintaining consistency with the lease-based design.
205
+
60
206
  ## Installation
61
207
 
62
208
  Add this line to your application's Gemfile:
data/Rakefile CHANGED
@@ -9,4 +9,9 @@ Rake::TestTask.new do |t|
9
9
  t.verbose = true
10
10
  end
11
11
 
12
- task default: [:test, :standard]
12
+ task :generate_typedefs do
13
+ `bundle exec sord rbi/counting_semaphore.rbi`
14
+ `bundle exec sord sig/counting_semaphore.rbs`
15
+ end
16
+
17
+ task default: [:test, :standard, :generate_typedefs]
@@ -1,7 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # A counting semaphore that allows up to N concurrent operations.
2
4
  # When capacity is exceeded, operations block until resources become available.
5
+ # API compatible with concurrent-ruby's Semaphore class.
3
6
  module CountingSemaphore
4
7
  class LocalSemaphore
8
+ include WithLeaseSupport
9
+
5
10
  SLEEP_WAIT_SECONDS = 0.25
6
11
 
7
12
  # @return [Integer]
@@ -9,86 +14,163 @@ module CountingSemaphore
9
14
 
10
15
  # Initialize the semaphore with a maximum capacity.
11
16
  #
12
- # @param capacity [Integer] Maximum number of concurrent operations allowed
17
+ # @param capacity [Integer] Maximum number of concurrent operations allowed (also called permits)
13
18
  # @param logger [Logger] the logger
14
19
  # @raise [ArgumentError] if capacity is not positive
15
20
  def initialize(capacity, logger: CountingSemaphore::NullLogger)
16
- raise ArgumentError, "Capacity must be positive, got #{capacity}" unless capacity > 0
21
+ raise ArgumentError, "Capacity must be positive, got #{capacity}" unless capacity >= 1
17
22
  @capacity = capacity.to_i
18
- @leased = 0
23
+ @acquired = 0
19
24
  @mutex = Mutex.new
20
25
  @condition = ConditionVariable.new
21
26
  @logger = logger
22
27
  end
23
28
 
24
- # Acquire a lease for the specified number of tokens and execute the block.
25
- # Blocks until sufficient resources are available.
29
+ # Acquires the given number of permits from this semaphore, blocking until all are available.
30
+ #
31
+ # @param permits [Integer] Number of permits to acquire (default: 1)
32
+ # @return [CountingSemaphore::Lease] A lease object that must be passed to release()
33
+ # @raise [ArgumentError] if permits is not an integer or is less than one
34
+ def acquire(permits = 1)
35
+ permits = permits.to_i
36
+ raise ArgumentError, "Permits must be at least 1, got #{permits}" if permits < 1
37
+ if permits > @capacity
38
+ raise ArgumentError, "Cannot acquire #{permits} permits as capacity is only #{@capacity}"
39
+ end
40
+
41
+ loop do
42
+ acquired = @mutex.synchronize do
43
+ if (@capacity - @acquired) >= permits
44
+ @acquired += permits
45
+ @logger.debug { "Acquired #{permits} permits, now #{@acquired}/#{@capacity}" }
46
+ true
47
+ else
48
+ false
49
+ end
50
+ end
51
+
52
+ if acquired
53
+ lease_id = "local_#{object_id}_#{Time.now.to_f}_#{rand(1000000)}"
54
+ return CountingSemaphore::Lease.new(
55
+ semaphore: self,
56
+ id: lease_id,
57
+ permits: permits
58
+ )
59
+ end
60
+
61
+ @logger.debug { "Unable to acquire #{permits} permits, #{@acquired}/#{@capacity} in use, waiting" }
62
+ @mutex.synchronize do
63
+ @condition.wait(@mutex)
64
+ end
65
+ end
66
+ end
67
+
68
+ # Releases a previously acquired lease, returning the permits to the semaphore.
26
69
  #
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}"
70
+ # @param lease [CountingSemaphore::Lease] The lease object returned by acquire() or try_acquire()
71
+ # @return [nil]
72
+ # @raise [ArgumentError] if lease belongs to a different semaphore
73
+ def release(lease)
74
+ unless lease.semaphore == self
75
+ raise ArgumentError, "Lease belongs to a different semaphore"
38
76
  end
39
77
 
40
- # Handle zero tokens case - no waiting needed
41
- return yield if token_count.zero?
78
+ permits = lease.permits
42
79
 
43
- did_accept = false
44
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
80
+ @mutex.synchronize do
81
+ @acquired -= permits
82
+ @logger.debug { "Released #{permits} permits (lease: #{lease.id}), now #{@acquired}/#{@capacity}" }
83
+ @condition.broadcast # Signal waiting threads
84
+ end
85
+ nil
86
+ end
87
+
88
+ # Acquires the given number of permits from this semaphore, only if all are available
89
+ # at the time of invocation or within the timeout interval.
90
+ #
91
+ # @param permits [Integer] Number of permits to acquire (default: 1)
92
+ # @param timeout [Numeric, nil] Number of seconds to wait, or nil to return immediately (default: nil)
93
+ # @return [CountingSemaphore::Lease, nil] A lease object if successful, nil otherwise
94
+ # @raise [ArgumentError] if permits is not an integer or is less than one
95
+ def try_acquire(permits = 1, timeout: nil)
96
+ permits = permits.to_i
97
+ raise ArgumentError, "Permits must be at least 1, got #{permits}" if permits < 1
98
+ if permits > @capacity
99
+ return nil
100
+ end
101
+
102
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) if timeout
45
103
 
46
104
  loop do
47
105
  # 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)
106
+ if timeout
107
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
108
+ return nil if elapsed_time >= timeout
51
109
  end
52
110
 
53
- did_accept = @mutex.synchronize do
54
- if (@capacity - @leased) >= token_count
55
- @leased += token_count
111
+ acquired = @mutex.synchronize do
112
+ if (@capacity - @acquired) >= permits
113
+ @acquired += permits
114
+ @logger.debug { "Acquired #{permits} permits (try), now #{@acquired}/#{@capacity}" }
56
115
  true
57
116
  else
58
117
  false
59
118
  end
60
119
  end
61
120
 
62
- if did_accept
63
- @logger.debug { "Leased #{token_count} and now in use #{@leased}/#{@capacity}" }
64
- return yield
121
+ if acquired
122
+ lease_id = "local_#{object_id}_#{Time.now.to_f}_#{rand(1000000)}"
123
+ return CountingSemaphore::Lease.new(
124
+ semaphore: self,
125
+ id: lease_id,
126
+ permits: permits
127
+ )
65
128
  end
66
129
 
67
- @logger.debug { "Unable to lease #{token_count}, #{@leased}/#{@capacity} waiting" }
130
+ # If no timeout, return immediately
131
+ return nil if timeout.nil?
132
+
133
+ # Wait with remaining timeout
134
+ remaining_timeout = timeout - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time)
135
+ return nil if remaining_timeout <= 0
68
136
 
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
137
  @mutex.synchronize do
81
- @leased -= token_count
82
- @condition.broadcast # Signal waiting threads
138
+ @condition.wait(@mutex, remaining_timeout)
83
139
  end
84
140
  end
85
141
  end
86
142
 
87
- # Get the current number of tokens currently leased
143
+ # Returns the current number of permits available in this semaphore.
144
+ #
145
+ # @return [Integer] Number of available permits
146
+ def available_permits
147
+ @mutex.synchronize { @capacity - @acquired }
148
+ end
149
+
150
+ # Acquires and returns all permits that are immediately available.
151
+ # Returns a single lease representing all drained permits.
88
152
  #
89
- # @return [Integer] Number of tokens currently in use
90
- def currently_leased
91
- @mutex.synchronize { @leased }
153
+ # @return [CountingSemaphore::Lease, nil] A lease for all available permits, or nil if none available
154
+ def drain_permits
155
+ permits = @mutex.synchronize do
156
+ available = @capacity - @acquired
157
+ if available > 0
158
+ @acquired = @capacity
159
+ @logger.debug { "Drained #{available} permits" }
160
+ available
161
+ else
162
+ 0
163
+ end
164
+ end
165
+
166
+ if permits > 0
167
+ lease_id = "local_#{object_id}_#{Time.now.to_f}_#{rand(1000000)}"
168
+ CountingSemaphore::Lease.new(
169
+ semaphore: self,
170
+ id: lease_id,
171
+ permits: permits
172
+ )
173
+ end
92
174
  end
93
175
  end
94
176
  end
@@ -1,28 +1,55 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CountingSemaphore
2
- # A null logger that discards all log messages
3
- # Provides the same interface as a real logger but does nothing
4
+ # A null logger that discards all log messages.
5
+ # Provides the same interface as a real logger but does nothing.
4
6
  # Only yields blocks when ENV["RUN_ALL_LOGGER_BLOCKS"] is set to "yes",
5
7
  # which is useful in testing. Block form for Logger calls allows you
6
8
  # to skip block evaluation if the Logger level is higher than your
7
9
  # call, and thus bugs can nest in those Logger blocks. During
8
10
  # testing it is helpful to excercise those blocks unconditionally.
9
11
  module NullLogger
12
+ # Logs a debug message. Discards the message but may yield the block for testing.
13
+ #
14
+ # @param message [String, nil] Optional message to log (discarded)
15
+ # @yield Optional block that will only be executed if ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
16
+ # @return [nil]
10
17
  def debug(message = nil, &block)
11
18
  yield if block_given? && ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
12
19
  end
13
20
 
21
+ # Logs an info message. Discards the message but may yield the block for testing.
22
+ #
23
+ # @param message [String, nil] Optional message to log (discarded)
24
+ # @yield Optional block that will only be executed if ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
25
+ # @return [nil]
14
26
  def info(message = nil, &block)
15
27
  yield if block_given? && ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
16
28
  end
17
29
 
30
+ # Logs a warning message. Discards the message but may yield the block for testing.
31
+ #
32
+ # @param message [String, nil] Optional message to log (discarded)
33
+ # @yield Optional block that will only be executed if ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
34
+ # @return [nil]
18
35
  def warn(message = nil, &block)
19
36
  yield if block_given? && ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
20
37
  end
21
38
 
39
+ # Logs an error message. Discards the message but may yield the block for testing.
40
+ #
41
+ # @param message [String, nil] Optional message to log (discarded)
42
+ # @yield Optional block that will only be executed if ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
43
+ # @return [nil]
22
44
  def error(message = nil, &block)
23
45
  yield if block_given? && ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
24
46
  end
25
47
 
48
+ # Logs a fatal message. Discards the message but may yield the block for testing.
49
+ #
50
+ # @param message [String, nil] Optional message to log (discarded)
51
+ # @yield Optional block that will only be executed if ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
52
+ # @return [nil]
26
53
  def fatal(message = nil, &block)
27
54
  yield if block_given? && ENV["RUN_ALL_LOGGER_BLOCKS"] == "yes"
28
55
  end