semian 0.24.0 → 0.25.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: aaba2c88b92196d855605e58c4525a4a8b7c2bb0a4d0b865cab244e059750bb1
4
- data.tar.gz: cbd8a6b46fb102b15e9ed3b3d212502013687bcf1e857d924d453086527848c7
3
+ metadata.gz: c46f4408d72d0fe86b9d1199429942007e48dc5a43dc75dc8f361d3d85e369c9
4
+ data.tar.gz: c297a18a78e2fc02e3c3823c6488413bc6170d8bd718acdcc8995c96bf68b070
5
5
  SHA512:
6
- metadata.gz: 490119185621f843c028549cf213d806c2306bfaf0bb10ecf5a4de0134cd470dcde5c849b3119f3f1162fcde0b354045483ce929f3be180157abee76acfab350
7
- data.tar.gz: 7afc74eaeb93dfd3f609801a44a4c8267134660172a1614de59747f0efe50371a8e817d40d4f4e97668853fbb30029fb8745da0f9c211f40d5906291bf56d86b
6
+ metadata.gz: 115fe761cbc5e3cf7bdaabb2e28ae04c1ac594d9bad3b69b8c63c996d9b52bf2306e6a3de6892a65b069ed4033f0f53ca09f215b19ebde54e4616059bbb6784d
7
+ data.tar.gz: 94b77f1de5c869ce44384dc4e6b5fb2acbde52f7074a786130b52b48185ff367cd19541da1ecc298828fda06c679f431fe6fd11c7eee9565b882a6fe1432cb7c
data/README.md CHANGED
@@ -113,6 +113,10 @@ Semian.maximum_lru_size = 0
113
113
 
114
114
  # Minimum time in seconds a resource should be resident in the LRU cache (default: 300s)
115
115
  Semian.minimum_lru_time = 60
116
+
117
+ # If true, raise exceptions in case of a validation / constraint failure
118
+ # Otherwise, log in output
119
+ Semian.default_force_config_validation = false
116
120
  ```
117
121
 
118
122
  Note: `minimum_lru_time` is a stronger guarantee than `maximum_lru_size`. That
@@ -120,6 +124,10 @@ is, if a resource has been updated more recently than `minimum_lru_time` it
120
124
  will not be garbage collected, even if it would cause the LRU cache to grow
121
125
  larger than `maximum_lru_size`.
122
126
 
127
+ Note: `default_force_config_validation` set to `true` is a
128
+ **_potentially breaking change_**. Misconfigured Semians will raise errors, so
129
+ make sure that this is what you want. See more in [Configuration Validation](#configuration-validation).
130
+
123
131
  When instantiating a resource it now needs to be configured for Semian. This is
124
132
  done by passing `semian` as an argument when initializing the client. Examples
125
133
  built in adapters:
@@ -132,7 +140,8 @@ client = Mysql2::Client.new(host: "localhost", username: "root", semian: {
132
140
  tickets: 8, # See the Understanding Semian section on picking these values
133
141
  success_threshold: 2,
134
142
  error_threshold: 3,
135
- error_timeout: 10
143
+ error_timeout: 10,
144
+ force_config_validation: false
136
145
  })
137
146
 
138
147
  # Redis client
@@ -145,6 +154,32 @@ client = Redis.new(semian: {
145
154
  })
146
155
  ```
147
156
 
157
+ #### Configuration Validation
158
+
159
+ Semian now provides a flag to specify log-based and exception-based configuration validation. To
160
+ explicitly force the Semian to validate it's configurations, pass `force_config_validation: true`
161
+ into your resource. This will raise an error in the case of a misconfigured or illegal Semian. Otherwise,
162
+ if it is set to `false`, it will log misconfigured parameters verbosely in output.
163
+
164
+ If not specified, it will use `Semian.default_force_config_validation` as
165
+ the flag.
166
+
167
+ ##### Migration Strategy for Force Config Validation
168
+
169
+ When migrating to use `force_config_validation: true`, follow these steps:
170
+
171
+ 1. **Deploy with it turned off**: Start with `force_config_validation: false` in your configuration
172
+ 2. **Look for logs with prefix**: Monitor your application logs for entries with the `[SEMIAN_CONFIG_WARNING]:` prefix. These logs will indicate misconfigured Semian resources
173
+ 3. **Iterate to fix**: Address each configuration issue identified in the logs by updating your Semian configurations
174
+ 4. **Enable**: Once all configuration issues are resolved, set `force_config_validation: true` to enable strict validation
175
+
176
+ Example log entries to look for:
177
+ ```
178
+ [SEMIAN_CONFIG_WARNING]: Missing required arguments for Semian: [:success_threshold, :error_threshold, :error_timeout]
179
+ [SEMIAN_CONFIG_WARNING]: Both bulkhead and circuitbreaker cannot be disabled.
180
+ [SEMIAN_CONFIG_WARNING]: Bulkhead configuration require either the :tickets or :quota parameter, you provided neither
181
+ ```
182
+
148
183
  #### Thread Safety
149
184
 
150
185
  Semian's circuit breaker implementation is thread-safe by default as of
@@ -284,11 +319,11 @@ Semian::NetHTTP.semian_configuration = proc do |host, port|
284
319
  SEMIAN_PARAMETERS.merge(name: name)
285
320
  end
286
321
 
287
- # Two requests to example.com can use two different semian resources,
322
+ # Two requests to shopify.com can use two different semian resources,
288
323
  # as long as `CurrentSemianSubResource.sub_name` is set accordingly:
289
- # CurrentSemianSubResource.set(sub_name: "sub_resource_1") { Net::HTTP.get_response(URI("http://example.com")) }
324
+ # CurrentSemianSubResource.set(sub_name: "sub_resource_1") { Net::HTTP.get_response(URI("http://shopify.com")) }
290
325
  # and:
291
- # CurrentSemianSubResource.set(sub_name: "sub_resource_2") { Net::HTTP.get_response(URI("http://example.com")) }
326
+ # CurrentSemianSubResource.set(sub_name: "sub_resource_2") { Net::HTTP.get_response(URI("http://shopify.com")) }
292
327
  ```
293
328
 
294
329
  For most purposes, `"#{host}_#{port}"` is a good default `name`. Custom `name` formats
@@ -903,6 +938,46 @@ Running Tests:
903
938
 
904
939
  - `$ bundle exec rake` Run with `SKIP_FLAKY_TESTS=true` to skip flaky tests (CI runs all tests)
905
940
 
941
+ ### Interactive Test Debugging
942
+
943
+ To use the interactive debugger on vscode:
944
+ - Open semian in vscode
945
+ - Create an `.env` file (if it doesn't exist)
946
+ - Set up a `DEBUG` ENV variable (ex; `DEBUG=true`)
947
+ - Under the `.vscode/` subdirectory, create a `launch.json` file, and include the following:
948
+
949
+ ```json
950
+ {
951
+ "configurations": [
952
+ {
953
+ "type": "rdbg",
954
+ "name": "Attach to Ruby rdbg",
955
+ "request": "attach",
956
+ "debugPort": "12345",
957
+ }
958
+ ]
959
+ }
960
+ ```
961
+
962
+ - For universal support, for any lines you would like to add breakpoints to in your `_test.rb` file (under `test/`), include the following snippet near the line of interest:
963
+
964
+ ```rb
965
+ require "debug"
966
+ binding.break if ENV["DEBUG"]
967
+ ```
968
+
969
+ **Note:** unless you are using an vscode extension such as [Dev Container](https://code.visualstudio.com/docs/devcontainers/tutorial), **do not use the built-in vscode breakpoints -- they will not work!**
970
+
971
+ - Start up the test container
972
+
973
+ ```shell
974
+ $ docker-compose -f .devcontainer/docker-compose.yml --profile test up -d
975
+ ```
976
+
977
+ - When the process indicates that it is waiting for the debugger connection, go to the `Run and Debug` tab, and execute the `Attach to Ruby rdbg` debugger
978
+
979
+ - Use the vscode debugging tools (such as step in, step out, pause, resume) as normal
980
+
906
981
  ## Everything else
907
982
 
908
983
  Test semian in containers:
@@ -917,7 +992,7 @@ If you make any changes to `.devcontainer/` you'd need to recreate the container
917
992
  Run tests in containers:
918
993
 
919
994
  ```shell
920
- $ docker-compose -f ./.devcontainer/docker-compose.yml run --rm test
995
+ $ docker-compose -f ./.devcontainer/docker-compose.yml --profile test run --rm test
921
996
  ```
922
997
 
923
998
  Running Tests:
@@ -29,10 +29,6 @@ module Semian
29
29
  @half_open_resource_timeout = half_open_resource_timeout
30
30
  @lumping_interval = lumping_interval
31
31
 
32
- if @lumping_interval > @error_threshold_timeout
33
- raise ArgumentError, "lumping_interval (#{@lumping_interval}) must be less than error_threshold_timeout (#{@error_threshold_timeout})"
34
- end
35
-
36
32
  @errors = implementation::SlidingWindow.new(max_size: @error_count_threshold)
37
33
  @successes = implementation::Integer.new
38
34
  @state = implementation::State.new
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Semian
4
+ class ConfigurationValidator
5
+ def initialize(name, configuration)
6
+ @name = name
7
+ @configuration = configuration
8
+ @adapter = configuration[:adapter]
9
+ @force_config_validation = force_config_validation?
10
+
11
+ unless @force_config_validation
12
+ Semian.logger.warn(
13
+ "Semian is running in log-mode for configuration validation. This means that Semian will not raise an error if the configuration is invalid. This is not recommended for production environments.\n\n[IMPORTANT] IN FUTURE RELEASES, STRICT CONFIGURATION VALIDATION WILL BE THE DEFAULT BEHAVIOR. PLEASE UPDATE YOUR CONFIGURATION TO USE `force_config_validation: true` TO ENABLE STRICT CONFIGURATION VALIDATION. ALLOWING MISCONFIGURATIONS IN FUTURE RELEASES WILL BREAK YOUR SEMIAN.\n---\n",
14
+ )
15
+ end
16
+ end
17
+
18
+ def validate!
19
+ validate_circuit_breaker_or_bulkhead!
20
+ validate_bulkhead_configuration!
21
+ validate_circuit_breaker_configuration!
22
+ validate_resource_name!
23
+ end
24
+
25
+ private
26
+
27
+ def hint_format(message)
28
+ "\n\nHINT: #{message}\n---"
29
+ end
30
+
31
+ def raise_or_log_validation_required!(message)
32
+ if @force_config_validation
33
+ raise ArgumentError, message
34
+ else
35
+ Semian.logger.warn("[SEMIAN_CONFIG_WARNING]: #{message}")
36
+ end
37
+ end
38
+
39
+ def require_keys!(required, options)
40
+ diff = required - options.keys
41
+ unless diff.empty?
42
+ raise_or_log_validation_required!("Missing required arguments for Semian: #{diff}")
43
+ end
44
+ end
45
+
46
+ def validate_circuit_breaker_or_bulkhead!
47
+ if (@configuration[:circuit_breaker] == false || ENV.key?("SEMIAN_CIRCUIT_BREAKER_DISABLED")) && (@configuration[:bulkhead] == false || ENV.key?("SEMIAN_BULKHEAD_DISABLED"))
48
+ raise_or_log_validation_required!("Both bulkhead and circuitbreaker cannot be disabled.")
49
+ end
50
+ end
51
+
52
+ def validate_bulkhead_configuration!
53
+ return if ENV.key?("SEMIAN_BULKHEAD_DISABLED")
54
+ return unless @configuration.fetch(:bulkhead, true)
55
+
56
+ tickets = @configuration[:tickets]
57
+ quota = @configuration[:quota]
58
+
59
+ if tickets.nil? && quota.nil?
60
+ raise_or_log_validation_required!("Bulkhead configuration require either the :tickets or :quota parameter, you provided neither")
61
+ end
62
+
63
+ if tickets && quota
64
+ raise_or_log_validation_required!("Bulkhead configuration require either the :tickets or :quota parameter, you provided both")
65
+ end
66
+
67
+ validate_quota!(quota) if quota
68
+ validate_tickets!(tickets) if tickets
69
+ end
70
+
71
+ def validate_circuit_breaker_configuration!
72
+ return if ENV.key?("SEMIAN_CIRCUIT_BREAKER_DISABLED")
73
+ return unless @configuration.fetch(:circuit_breaker, true)
74
+
75
+ require_keys!([:success_threshold, :error_threshold, :error_timeout], @configuration)
76
+ validate_thresholds!
77
+ validate_timeouts!
78
+ end
79
+
80
+ def validate_thresholds!
81
+ success_threshold = @configuration[:success_threshold]
82
+ error_threshold = @configuration[:error_threshold]
83
+
84
+ unless success_threshold.is_a?(Integer) && success_threshold > 0
85
+ err = "success_threshold must be a positive integer, got #{success_threshold}"
86
+
87
+ if success_threshold == 0
88
+ err += hint_format("Are you sure that this is what you want? This will close the circuit breaker immediately after `error_timeout` seconds without checking the resource!")
89
+ end
90
+
91
+ raise_or_log_validation_required!(err)
92
+ end
93
+
94
+ unless error_threshold.is_a?(Integer) && error_threshold > 0
95
+ err = "error_threshold must be a positive integer, got #{error_threshold}"
96
+
97
+ if error_threshold == 0
98
+ err += hint_format("Are you sure that this is what you want? This can result in the circuit opening up at unpredictable times!")
99
+ end
100
+
101
+ raise_or_log_validation_required!(err)
102
+ end
103
+ end
104
+
105
+ def validate_timeouts!
106
+ error_timeout = @configuration[:error_timeout]
107
+ error_threshold_timeout_enabled = @configuration[:error_threshold_timeout_enabled].nil? ? true : @configuration[:error_threshold_timeout_enabled]
108
+ error_threshold = @configuration[:error_threshold]
109
+ lumping_interval = @configuration[:lumping_interval]
110
+ half_open_resource_timeout = @configuration[:half_open_resource_timeout]
111
+
112
+ unless error_timeout.is_a?(Numeric) && error_timeout > 0
113
+ err = "error_timeout must be a positive number, got #{error_timeout}"
114
+
115
+ if error_timeout == 0
116
+ err += hint_format("Are you sure that this is what you want? This will close the circuit breaker immediately after opening it!")
117
+ end
118
+
119
+ raise_or_log_validation_required!(err)
120
+ end
121
+
122
+ # This state checks for contradictions between error_threshold_timeout_enabled and error_threshold_timeout.
123
+ unless error_threshold_timeout_enabled || !@configuration[:error_threshold_timeout]
124
+ err = "error_threshold_timeout_enabled and error_threshold_timeout must not contradict each other, got error_threshold_timeout_enabled: #{error_threshold_timeout_enabled}, error_threshold_timeout: #{@configuration[:error_threshold_timeout]}"
125
+ err += hint_format("Are you sure this is what you want? This will set error_threshold_timeout_enabled to #{error_threshold_timeout_enabled} while error_threshold_timeout is #{@configuration[:error_threshold_timeout] ? "truthy" : "falsy"}")
126
+
127
+ raise_or_log_validation_required!(err)
128
+ end
129
+
130
+ # Only set this after we have checked the error_threshold_timeout_enabled condition
131
+ error_threshold_timeout = @configuration[:error_threshold_timeout] || error_timeout
132
+ unless error_threshold_timeout.is_a?(Numeric) && error_threshold_timeout > 0
133
+ err = "error_threshold_timeout must be a positive number, got #{error_threshold_timeout}"
134
+
135
+ if error_threshold_timeout == 0
136
+ err += hint_format("Are you sure that this is what you want? This will almost never open the circuit breaker since the time interval to catch errors is 0!")
137
+ end
138
+
139
+ raise_or_log_validation_required!(err)
140
+ end
141
+
142
+ unless half_open_resource_timeout.nil? || (half_open_resource_timeout.is_a?(Numeric) && half_open_resource_timeout > 0)
143
+ err = "half_open_resource_timeout must be a positive number, got #{half_open_resource_timeout}"
144
+
145
+ if half_open_resource_timeout == 0
146
+ err += hint_format("Are you sure that this is what you want? This will never half-open the circuit breaker! If that's what you want, you can omit the option instead")
147
+ end
148
+
149
+ raise_or_log_validation_required!(err)
150
+ end
151
+
152
+ unless lumping_interval.nil? || (lumping_interval.is_a?(Numeric) && lumping_interval > 0)
153
+ err = "lumping_interval must be a positive number, got #{lumping_interval}"
154
+
155
+ if lumping_interval == 0
156
+ err += hint_format("Are you sure that this is what you want? This will never lump errors! If that's what you want, you can omit the option instead")
157
+ end
158
+
159
+ raise_or_log_validation_required!(err)
160
+ end
161
+
162
+ # You might be wondering why not check just check lumping_interval * error_threshold <= error_threshold_timeout
163
+ # The reason being is that since the lumping_interval starts at the first error, we count the first error
164
+ # at second 0. So we need to subtract 1 from the error_threshold to get the correct minimum time to reach the
165
+ # error threshold. error_threshold_timeout cannot be less than this minimum time.
166
+ #
167
+ # For example,
168
+ #
169
+ # error_threshold = 3
170
+ # error_threshold_timeout = 10
171
+ # lumping_interval = 4
172
+ #
173
+ # The first error could be counted at second 0, the second error could be counted at second 4, and the third
174
+ # error could be counted at second 8. So this is a valid configuration.
175
+
176
+ unless lumping_interval.nil? || error_threshold_timeout.nil? || lumping_interval * (error_threshold - 1) <= error_threshold_timeout
177
+ err = "constraint violated, this circuit breaker can never open! lumping_interval * (error_threshold - 1) should be <= error_threshold_timeout, got lumping_interval: #{lumping_interval}, error_threshold: #{error_threshold}, error_threshold_timeout: #{error_threshold_timeout}"
178
+ err += hint_format("lumping_interval starts from the first error and not in a fixed window. So you can fit n errors in n-1 seconds, since error 0 starts at 0 seconds. Ensure that you can fit `error_threshold` errors lumped in `lumping_interval` seconds within `error_threshold_timeout` seconds.")
179
+
180
+ raise_or_log_validation_required!(err)
181
+ end
182
+ end
183
+
184
+ def validate_quota!(quota)
185
+ unless quota.is_a?(Numeric) && quota > 0 && quota < 1
186
+ err = "quota must be a decimal between 0 and 1, got #{quota}"
187
+
188
+ if quota == 0
189
+ err += hint_format("Are you sure that this is what you want? This is the same as assigning no workers to the resource, disabling the resource!")
190
+ elsif quota == 1
191
+ err += hint_format("Are you sure that this is what you want? This is the same as assigning all workers to the resource, disabling the bulkhead!")
192
+ end
193
+
194
+ raise_or_log_validation_required!(err)
195
+ end
196
+ end
197
+
198
+ def validate_tickets!(tickets)
199
+ unless tickets.is_a?(Integer) && tickets > 0 && tickets < Semian::MAX_TICKETS
200
+ err = "ticket count must be a positive integer and less than #{Semian::MAX_TICKETS}, got #{tickets}"
201
+
202
+ if tickets == 0
203
+ err += hint_format("Are you sure that this is what you want? This is the same as assigning no workers to the resource, disabling the resource!")
204
+ elsif tickets == Semian::MAX_TICKETS
205
+ err += hint_format("Are you sure that this is what you want? This is the same as assigning all workers to the resource, disabling the bulkhead!")
206
+ end
207
+
208
+ raise_or_log_validation_required!(err)
209
+ end
210
+ end
211
+
212
+ def validate_resource_name!
213
+ unless @name.is_a?(String) || @name.is_a?(Symbol)
214
+ raise_or_log_validation_required!("name must be a symbol or string, got #{@name}")
215
+ end
216
+
217
+ if Semian.resources[@name]
218
+ err = "Resource with name #{@name} is already registered"
219
+ err += hint_format("Are you sure that this is what you want? This will override an existing resource with the same name!")
220
+
221
+ raise_or_log_validation_required!(err)
222
+ end
223
+ end
224
+
225
+ def force_config_validation?
226
+ if @configuration[:force_config_validation].nil?
227
+ Semian.default_force_config_validation
228
+ else
229
+ @configuration[:force_config_validation]
230
+ end
231
+ end
232
+ end
233
+ end
@@ -14,11 +14,11 @@ class LRUHash
14
14
  yield
15
15
  end
16
16
 
17
- def try_lock
17
+ def try_lock # rubocop:disable Naming/PredicateMethod
18
18
  true
19
19
  end
20
20
 
21
- def unlock
21
+ def unlock # rubocop:disable Naming/PredicateMethod
22
22
  true
23
23
  end
24
24
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Semian
4
- VERSION = "0.24.0"
4
+ VERSION = "0.25.0"
5
5
  end
data/lib/semian.rb CHANGED
@@ -16,6 +16,7 @@ require "semian/simple_sliding_window"
16
16
  require "semian/simple_integer"
17
17
  require "semian/simple_state"
18
18
  require "semian/lru_hash"
19
+ require "semian/configuration_validator"
19
20
 
20
21
  #
21
22
  # === Overview
@@ -102,11 +103,12 @@ module Semian
102
103
  OpenCircuitError = Class.new(BaseError)
103
104
  SemaphoreMissingError = Class.new(BaseError)
104
105
 
105
- attr_accessor :maximum_lru_size, :minimum_lru_time, :default_permissions, :namespace
106
+ attr_accessor :maximum_lru_size, :minimum_lru_time, :default_permissions, :namespace, :default_force_config_validation
106
107
 
107
108
  self.maximum_lru_size = 500
108
109
  self.minimum_lru_time = 300 # 300 seconds / 5 minutes
109
110
  self.default_permissions = 0660
111
+ self.default_force_config_validation = false
110
112
 
111
113
  def issue_disabled_semaphores_warning
112
114
  return if defined?(@warning_issued)
@@ -184,13 +186,12 @@ module Semian
184
186
  def register(name, **options)
185
187
  return UnprotectedResource.new(name) if ENV.key?("SEMIAN_DISABLED")
186
188
 
189
+ # Validate configuration before proceeding
190
+ ConfigurationValidator.new(name, options).validate!
191
+
187
192
  circuit_breaker = create_circuit_breaker(name, **options)
188
193
  bulkhead = create_bulkhead(name, **options)
189
194
 
190
- if circuit_breaker.nil? && bulkhead.nil?
191
- raise ArgumentError, "Both bulkhead and circuitbreaker cannot be disabled."
192
- end
193
-
194
195
  resources[name] = ProtectedResource.new(name, bulkhead, circuit_breaker)
195
196
  end
196
197
 
@@ -296,8 +297,6 @@ module Semian
296
297
  return if ENV.key?("SEMIAN_CIRCUIT_BREAKER_DISABLED")
297
298
  return unless options.fetch(:circuit_breaker, true)
298
299
 
299
- require_keys!([:success_threshold, :error_threshold, :error_timeout], options)
300
-
301
300
  exceptions = options[:exceptions] || []
302
301
  CircuitBreaker.new(
303
302
  name,
@@ -351,13 +350,6 @@ module Semian
351
350
  timeout: timeout,
352
351
  )
353
352
  end
354
-
355
- def require_keys!(required, options)
356
- diff = required - options.keys
357
- unless diff.empty?
358
- raise ArgumentError, "Missing required arguments for Semian: #{diff}"
359
- end
360
- end
361
353
  end
362
354
 
363
355
  if Semian.semaphores_enabled?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: semian
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Francis
@@ -36,6 +36,7 @@ files:
36
36
  - lib/semian/activerecord_trilogy_adapter.rb
37
37
  - lib/semian/adapter.rb
38
38
  - lib/semian/circuit_breaker.rb
39
+ - lib/semian/configuration_validator.rb
39
40
  - lib/semian/grpc.rb
40
41
  - lib/semian/instrumentable.rb
41
42
  - lib/semian/lru_hash.rb
@@ -77,7 +78,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
77
78
  - !ruby/object:Gem::Version
78
79
  version: '0'
79
80
  requirements: []
80
- rubygems_version: 3.6.9
81
+ rubygems_version: 3.7.1
81
82
  specification_version: 4
82
83
  summary: Bulkheading for Ruby with SysV semaphores
83
84
  test_files: []