sidekiq-ratomic-pool 0.1.0 → 0.3.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: 73e5869196bd322f91cea20c1332e39b2c2e3c9d81e99717ac74210fb766be36
4
- data.tar.gz: 4b830bdc58727ba25cc653011bf52d1394d2344ad5de996a7f20965b9b404e57
3
+ metadata.gz: f6497422ce457ce3e5aab57c5e1d7de44aec7b7a6f2784e83a58c0a8419f8b19
4
+ data.tar.gz: 36f559671bcf385abe58c0d98d9762eb952d97b70faa8ec4830a730106aa03b1
5
5
  SHA512:
6
- metadata.gz: 6272ccc17b365724a851f3d6b39cb82d4ce109068cc854a640ab0b649f2c062bf4ee6afdeccf0c6e442789dfe03be411eb3bf8abd8dab2da36d8f9b5f8d09e00
7
- data.tar.gz: 71f001b3aca75e43792bf760795d037a6f7a220eb15de33da7b1802eab89e976186ebce147f6eeb21f95e88c702ffd05c8309b06a410831609b58ff613133417
6
+ metadata.gz: 79fef0ed157daa34e338156639ae19346118ab2f72b284d242da0f592ee6910ec19f33433ed6cec0044e1303df633ae47f1af21d2d5c13d943b7015a10ae8e42
7
+ data.tar.gz: e3ea3f1ed5016670de28fcca67629a599e8ef491915114aa3bff5509920d9e97e3b257380c3bba6e18d178c621d2f916d502b04836743bb533b35c77a527aca3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-08-26
4
+
5
+ - Added a dedicated `smoke_test/benchmark/` harness for comparative end-to-end
6
+ throughput measurements using real Redis, Sidekiq, and Ratomic pooling.
7
+ - Added configurable benchmark workloads with elapsed-time, jobs-per-second, and
8
+ Sidekiq process/thread scheduling output.
9
+ - Added a matched `connection_pool` comparison runner using the same Redis,
10
+ Sidekiq, pooling, health-check, and workload settings.
11
+ - Added a Ractor-native `4 × 20` benchmark and a four-process Sidekiq benchmark
12
+ demonstrating 80 total worker/resource capacity across independent runtimes.
13
+ - Made the Ractor benchmark scalable for large job counts by using fixed worker
14
+ threads with queued jobs instead of creating one Ruby thread per job.
15
+ - Added explicit Ractor-shareability validation for resource factories with a
16
+ clear configuration error for unsupported factory state.
17
+ - Added custom validator callability validation and regression coverage for the
18
+ new Ractor-boundary configuration checks.
19
+ - Added configurable LocalPool checkout timeouts through `pool_timeout`, with
20
+ synchronized middleware runtime adoption and RBS coverage.
21
+ - Expanded the host-owned Ractor adapter with CPU-based defaults, bounded job
22
+ queues, configurable topology/workload controls, per-Ractor activity metrics,
23
+ and controlled failure cancellation/reporting.
24
+ - Aligned native and four-process Sidekiq benchmark defaults for workload and
25
+ checkout timeout, added runtime/version/topology metadata, and hardened short
26
+ benchmark snapshot handling.
27
+ - Linked the benchmark from the smoke-test documentation.
28
+ - Expanded YARD documentation for all public `Sidekiq::Ratomic::Pool` attribute
29
+ readers and clarified the host-owned Ractor integration contract.
30
+ - Added elapsed-time and jobs-per-second metrics to the host-owned Ractor
31
+ adapter for direct comparison with the Sidekiq benchmark.
32
+ - Added Redis server version metadata to the real-Redis benchmark runners.
33
+ - Converted the host-owned Ractor adapter to real Redis resources and persisted
34
+ a reproducible command for comparing its topology and throughput.
35
+ - Aligned the `connection_pool` comparison runner with the Ratomic benchmark's
36
+ knobs, Redis workload, metadata, and output shape for apples-to-apples runs.
37
+ - Enforced a single in-flight half-open circuit probe with concurrent fast
38
+ failure for competing callers.
39
+ - Added regression coverage for host-coordinated Ractor cancellation and
40
+ simultaneous failure isolation across multiple Ractors.
41
+
42
+ ## [0.2.0] - 2026-08-25
43
+
44
+ - Added a real Redis-backed `smoke_test/` harness with standalone Sidekiq client
45
+ and server examples, Docker Compose Redis, and process/thread scheduling output.
46
+ - Made the middleware compatible with Sidekiq's per-job middleware instantiation
47
+ by sharing each configured pool runtime across middleware instances.
48
+ - Added support for Sidekiq-style positional middleware options and validation for
49
+ unknown options.
50
+ - Added smoke coverage for multi-threaded job processing across visible CPUs.
51
+
52
+ ## [0.1.0] - 2026-08-24
53
+
3
54
  - Added Sidekiq middleware with Ratomic `LocalPool` resource injection,
4
55
  health validation, exponential retries, and circuit-breaker fast failure.
5
56
  - Added thread-boundary pool behavior tests and synchronized RBS signatures for the
data/README.md CHANGED
@@ -27,21 +27,24 @@ and worker pool accessor pattern.
27
27
 
28
28
  ## Features
29
29
 
30
- - **Ractor-Local Isolation**: Each Ractor lazily owns its resources through `Ratomic::LocalPool`; threads within the same Ractor share that Ractor-local pool.
30
+ - **Ractor-Local Isolation**: A pool runtime used inside a Ractor lazily owns
31
+ its resources through `Ratomic::LocalPool`; threads within that Ractor share
32
+ its Ractor-local pool.
31
33
  - **Circuit Breaker Pattern**: Trips open after a configurable threshold of checkout, health-check, or configured retryable I/O failures.
32
34
  - **Exponential Backoff**: Applies increasing retry delays to transient checkout and retryable resource-operation failures.
33
35
  - **Automated Health Probes**: Validates resources with `ping`, `active?`, or a caller-supplied validator before use.
34
36
  - **Configurable Failure Policy**: Non-retryable worker exceptions propagate without changing circuit state, avoiding accidental duplicate work.
37
+ - **Ratomic-Native Failure Accounting**: Tracks circuit-breaker failures with the Ractor-shareable `Ratomic::Counter` primitive instead of adding `concurrent-ruby`.
35
38
 
36
39
  ## Usage
37
40
 
38
- Factories must be Ractor-shareable because `Ratomic::LocalPool` creates resources lazily
39
- inside each Ractor. A small frozen factory object is suitable for production use:
41
+ Factories are made Ractor-shareable because `Ratomic::LocalPool` creates resources
42
+ lazily inside each Ractor. A small frozen factory object is suitable for production use:
40
43
 
41
44
  ```ruby
42
45
  RedisFactory = Data.define(:url) do
43
46
  def call
44
- RedisClient.new(url:)
47
+ RedisClient.config(url:).new_client
45
48
  end
46
49
  end
47
50
 
@@ -50,6 +53,7 @@ Sidekiq.configure_server do |config|
50
53
  chain.add Sidekiq::Ratomic::Pool,
51
54
  pool_name: :redis_pool,
52
55
  size: 10,
56
+ pool_timeout: 1,
53
57
  max_retries: 3,
54
58
  retry_delay: 0.2,
55
59
  cb_threshold: 5,
@@ -69,6 +73,81 @@ Resource checkout/health failures and configured retryable I/O errors use expone
69
73
  Other exceptions raised by the worker block propagate without being retried, preventing
70
74
  accidental duplication of non-idempotent work.
71
75
 
76
+ ### Host-owned Ractor scheduling
77
+
78
+ This gem provides Ractor-safe, Ractor-local resource ownership; it does not
79
+ create Ractors or dispatch Sidekiq jobs into them. The host framework or
80
+ application owns Ractor creation, job routing, supervision, and shutdown.
81
+
82
+ `Sidekiq::Ratomic::Pool` contains mutable circuit-breaker coordination state and
83
+ is not itself required to be Ractor-shareable. A Ractor-aware host should pass
84
+ only shareable configuration and factory data into each Ractor, construct that
85
+ Ractor's pool runtime there, and execute the resource-backed work inside the
86
+ same Ractor:
87
+
88
+ ```ruby
89
+ PoolInput = Data.define(:pool_name, :size, :pool_timeout, :factory, :jobs)
90
+
91
+ ractor = Ractor.new(
92
+ Ractor.make_shareable(
93
+ PoolInput.new(:redis_pool, 20, 1, RedisFactory.new(ENV.fetch('REDIS_URL')), 100)
94
+ )
95
+ ) do |input|
96
+ pool = Sidekiq::Ratomic::Pool.new(
97
+ pool_name: input.pool_name,
98
+ size: input.size,
99
+ pool_timeout: input.pool_timeout,
100
+ factory: input.factory
101
+ )
102
+
103
+ input.jobs.times do
104
+ pool.with { |resource| resource.call('PING') }
105
+ end
106
+
107
+ pool.close
108
+ :complete
109
+ end
110
+
111
+ ractor.value
112
+ ```
113
+
114
+ Threads created by the host inside that Ractor use the same Ractor-local pool.
115
+ Resources must not be returned to, or used by, another Ractor. See the
116
+ [`Ractor-local pooling implementation plan`](docs/ractor-local-pooling-implementation-plan.md)
117
+ for the integration contract and rollout criteria.
118
+
119
+ The opt-in host adapter example demonstrates bounded dispatch, real Redis
120
+ connections, and per-Ractor activity metrics without making Ractor scheduling
121
+ part of the gem:
122
+
123
+ ```bash
124
+ cd smoke_test
125
+ docker compose up -d redis
126
+ REDIS_URL="${REDIS_URL:-redis://127.0.0.1:6380/0}" \
127
+ RACTOR_NATIVE_COUNT=4 \
128
+ RACTOR_NATIVE_THREADS=20 \
129
+ RATOMIC_POOL_SIZE=20 \
130
+ RATOMIC_POOL_TIMEOUT=1 \
131
+ RACTOR_QUEUE_CAPACITY=40 \
132
+ BENCHMARK_JOBS_PER_RACTOR=5000 \
133
+ BENCHMARK_WORK_SECONDS=0.05 \
134
+ bundle exec ruby ./benchmark/ractor_host_adapter.rb
135
+ ```
136
+
137
+ The adapter defaults its Ractor count to `Etc.nprocessors` when the variable
138
+ is omitted. That CPU-based default belongs to the example only; production
139
+ applications remain responsible for choosing and supervising their topology.
140
+ The adapter performs real Redis `PING`, `INCR`, and `HSET` operations and
141
+ reports Ruby, Sidekiq, Ratomic, and Redis versions, elapsed time, throughput,
142
+ and per-Ractor results. With four Ractors, the command above processes 20,000
143
+ jobs and demonstrates four independent pools of 20 resources, shared by 20
144
+ threads inside each Ractor.
145
+
146
+ The circuit breaker uses `Ratomic::Counter` for its failure count. This keeps the
147
+ counter aligned with Ratomic's Ractor-safe primitive model and avoids a separate
148
+ `concurrent-ruby` production dependency; the pool mutex still protects the
149
+ failure-count and circuit-state transition as one operation.
150
+
72
151
  ## Smoke test
73
152
 
74
153
  The [`smoke_test/`](smoke_test/) harness runs Redis in Docker, starts a standalone
@@ -80,3 +159,6 @@ Sidekiq worker threads and the CPU core (`PSR`) on which they were recently sche
80
159
  cd smoke_test
81
160
  ./run.sh
82
161
  ```
162
+
163
+ For comparative throughput measurements, see the [`smoke_test/benchmark/`](smoke_test/benchmark/)
164
+ harness.
@@ -6,7 +6,7 @@ module Sidekiq
6
6
  module Ratomic
7
7
  class Pool
8
8
  # Current gem version.
9
- VERSION = '0.1.0'
9
+ VERSION = '0.3.0'
10
10
  end
11
11
  end
12
12
  end
@@ -13,20 +13,72 @@ module Sidekiq
13
13
  #
14
14
  # Resources are validated before checkout and transient failures are retried
15
15
  # with exponential backoff. Persistent failures open the circuit breaker.
16
+ # rubocop:disable Metrics/ClassLength
16
17
  class Pool
17
- attr_reader :pool_name, :size, :max_retries, :retry_delay, :validator, :cb_threshold, :cb_timeout,
18
- :retryable_errors
18
+ # Name of the worker accessor populated by the middleware.
19
+ # @return [Symbol]
20
+ attr_reader :pool_name
19
21
 
20
- def initialize(pool_name:, size: 10, max_retries: 3, retry_delay: 0.2,
22
+ # Maximum number of resources owned by each Ractor-local pool.
23
+ # @return [Integer]
24
+ attr_reader :size
25
+
26
+ # Maximum time to wait for a resource checkout.
27
+ # @return [Numeric, nil] seconds, or nil to wait indefinitely
28
+ attr_reader :pool_timeout
29
+
30
+ # Maximum number of retries for checkout and configured retryable failures.
31
+ # @return [Integer]
32
+ attr_reader :max_retries
33
+
34
+ # Base delay used for exponential retry backoff.
35
+ # @return [Numeric] seconds
36
+ attr_reader :retry_delay
37
+
38
+ # Callback used to validate a checked-out resource.
39
+ # @return [#call]
40
+ attr_reader :validator
41
+
42
+ # Number of recorded failures required to open the circuit.
43
+ # @return [Integer]
44
+ attr_reader :cb_threshold
45
+
46
+ # Time an open circuit remains open before a half-open probe.
47
+ # @return [Numeric] seconds
48
+ attr_reader :cb_timeout
49
+
50
+ # Exception classes treated as retryable worker/resource failures.
51
+ # @return [Array<Class>]
52
+ attr_reader :retryable_errors
53
+
54
+ # rubocop:disable Metrics/MethodLength
55
+ def initialize(options = nil, pool_name: nil, size: 10, pool_timeout: 1.0, max_retries: 3, retry_delay: 0.2,
21
56
  cb_threshold: 5, cb_timeout: 30, validator: nil,
22
57
  retryable_errors: [IOError, SystemCallError, Timeout::Error], factory: nil, &block)
58
+ normalize_options!(options) do |config|
59
+ pool_name = config.fetch(:pool_name, pool_name)
60
+ size = config.fetch(:size, size)
61
+ pool_timeout = config.fetch(:pool_timeout, pool_timeout)
62
+ max_retries = config.fetch(:max_retries, max_retries)
63
+ retry_delay = config.fetch(:retry_delay, retry_delay)
64
+ cb_threshold = config.fetch(:cb_threshold, cb_threshold)
65
+ cb_timeout = config.fetch(:cb_timeout, cb_timeout)
66
+ validator = config.fetch(:validator, validator)
67
+ retryable_errors = config.fetch(:retryable_errors, retryable_errors)
68
+ factory = config.fetch(:factory, factory)
69
+ end
70
+
23
71
  factory ||= block
72
+ raise ArgumentError, 'A pool_name must be provided' unless pool_name
24
73
  raise ArgumentError, 'A resource factory must be provided' unless factory
25
74
 
26
- validate_options!(size, max_retries, retry_delay, cb_threshold, cb_timeout)
75
+ validate_options!(
76
+ size:, pool_timeout:, max_retries:, retry_delay:, cb_threshold:, cb_timeout:
77
+ )
27
78
 
28
79
  @pool_name = pool_name.to_sym
29
80
  @size = size
81
+ @pool_timeout = pool_timeout
30
82
  @max_retries = max_retries
31
83
  @retry_delay = retry_delay
32
84
  @cb_threshold = cb_threshold
@@ -35,11 +87,37 @@ module Sidekiq
35
87
  @retryable_errors = retryable_errors.freeze
36
88
  @state_mutex = Mutex.new
37
89
  @failure_count = ::Ratomic::Counter.new
38
- @state = :closed
39
- @last_state_change = monotonic_time
90
+ @state_holder = { state: :closed, last_state_change: monotonic_time, probe_in_flight: false }
40
91
 
41
- shareable_factory = Ractor.make_shareable(factory)
42
- @local_pool = ::Ratomic::LocalPool.new(size: @size, factory: shareable_factory)
92
+ raise ArgumentError, 'validator must respond to call' unless validator.nil? || validator.respond_to?(:call)
93
+
94
+ shareable_factory = make_shareable_factory(factory)
95
+ @local_pool = ::Ratomic::LocalPool.new(size: @size, timeout: @pool_timeout, factory: shareable_factory)
96
+ end
97
+
98
+ # Share one pool runtime across Sidekiq's per-job middleware instances.
99
+ def config=(config)
100
+ mutex = config.instance_variable_get(:@sidekiq_ratomic_pool_mutex)
101
+ unless mutex
102
+ mutex = Mutex.new
103
+ config.instance_variable_set(:@sidekiq_ratomic_pool_mutex, mutex)
104
+ end
105
+
106
+ runtimes = config.instance_variable_get(:@sidekiq_ratomic_pool_runtimes)
107
+ unless runtimes
108
+ runtimes = {} # : Hash[Symbol, Pool]
109
+ config.instance_variable_set(:@sidekiq_ratomic_pool_runtimes, runtimes)
110
+ end
111
+
112
+ mutex.synchronize do
113
+ runtime = runtimes[@pool_name]
114
+ if runtime
115
+ adopt_runtime(runtime)
116
+ else
117
+ runtimes[@pool_name] = self
118
+ end
119
+ end
120
+ @config = config
43
121
  end
44
122
 
45
123
  # Inject this pool into a worker's configured pool accessor.
@@ -58,7 +136,7 @@ module Sidekiq
58
136
 
59
137
  # Check out a healthy resource and yield it to the caller.
60
138
  def with
61
- check_circuit_state!
139
+ probe_reserved = check_circuit_state!
62
140
  attempts = 0
63
141
  work_failed = false
64
142
 
@@ -77,7 +155,10 @@ module Sidekiq
77
155
  result
78
156
  end
79
157
  rescue StandardError => e
80
- raise unless !work_failed || retryable_error?(e)
158
+ unless !work_failed || retryable_error?(e)
159
+ release_half_open_probe if probe_reserved
160
+ raise
161
+ end
81
162
 
82
163
  record_failure
83
164
  if attempts <= @max_retries && state != :open
@@ -93,20 +174,28 @@ module Sidekiq
93
174
  # Return the current circuit-breaker state.
94
175
  def state
95
176
  @state_mutex.synchronize do
96
- if @state == :open && monotonic_time - @last_state_change > @cb_timeout
97
- @state = :half_open
98
- @last_state_change = monotonic_time
99
- end
100
- @state
177
+ transition_to_half_open_if_ready
178
+ @state_holder[:state]
101
179
  end
102
180
  end
103
181
 
104
182
  private
105
183
 
106
184
  def check_circuit_state!
107
- return unless state == :open
185
+ @state_mutex.synchronize do
186
+ transition_to_half_open_if_ready
187
+ case @state_holder[:state]
188
+ when :open
189
+ raise Pool::CircuitOpenError, 'Circuit breaker is open'
190
+ when :half_open
191
+ raise Pool::CircuitOpenError, 'Circuit breaker probe is in flight' if @state_holder[:probe_in_flight]
108
192
 
109
- raise Pool::CircuitOpenError, 'Circuit breaker is open'
193
+ @state_holder[:probe_in_flight] = true
194
+ true
195
+ else
196
+ false
197
+ end
198
+ end
110
199
  end
111
200
 
112
201
  def verify_health(resource)
@@ -123,9 +212,11 @@ module Sidekiq
123
212
  error.is_a?(StandardError) && !error.is_a?(Pool::CheckoutError)
124
213
  end
125
214
 
126
- def validate_options!(size, max_retries, retry_delay, cb_threshold, cb_timeout)
215
+ def validate_options!(size:, pool_timeout:, max_retries:, retry_delay:, cb_threshold:, cb_timeout:)
127
216
  validations = [
128
217
  [size.is_a?(Integer) && size.positive?, 'size must be a positive Integer'],
218
+ [pool_timeout.nil? || (pool_timeout.is_a?(Numeric) && pool_timeout >= 0),
219
+ 'pool_timeout must be numeric or nil and non-negative'],
129
220
  [max_retries.is_a?(Integer) && max_retries >= 0, 'max_retries must be a non-negative Integer'],
130
221
  [retry_delay.is_a?(Numeric) && retry_delay >= 0, 'retry_delay must be non-negative'],
131
222
  [cb_threshold.is_a?(Integer) && cb_threshold.positive?, 'cb_threshold must be a positive Integer'],
@@ -136,6 +227,27 @@ module Sidekiq
136
227
  end
137
228
  end
138
229
 
230
+ def normalize_options!(options)
231
+ return unless options
232
+ raise ArgumentError, 'middleware options must be a Hash' unless options.is_a?(Hash)
233
+
234
+ config = options.transform_keys(&:to_sym)
235
+ allowed = %i[pool_name size pool_timeout max_retries retry_delay cb_threshold cb_timeout validator
236
+ retryable_errors factory]
237
+ unknown = config.keys - allowed
238
+ raise ArgumentError, "unknown middleware options: #{unknown.join(', ')}" unless unknown.empty?
239
+
240
+ yield config
241
+ end
242
+
243
+ def adopt_runtime(runtime)
244
+ @local_pool = runtime.instance_variable_get(:@local_pool)
245
+ @pool_timeout = runtime.instance_variable_get(:@pool_timeout)
246
+ @state_mutex = runtime.instance_variable_get(:@state_mutex)
247
+ @failure_count = runtime.instance_variable_get(:@failure_count)
248
+ @state_holder = runtime.instance_variable_get(:@state_holder)
249
+ end
250
+
139
251
  def default_validator(resource)
140
252
  return resource.ping if resource.respond_to?(:ping)
141
253
  return resource.active? if resource.respond_to?(:active?)
@@ -147,23 +259,50 @@ module Sidekiq
147
259
  @state_mutex.synchronize do
148
260
  failure_count = @failure_count.value
149
261
  @failure_count.decrement(failure_count) unless failure_count.zero?
150
- @state = :closed if @state == :half_open
262
+ if @state_holder[:state] == :half_open
263
+ @state_holder[:state] = :closed
264
+ @state_holder[:probe_in_flight] = false
265
+ end
151
266
  end
152
267
  end
153
268
 
154
269
  def record_failure
155
270
  @state_mutex.synchronize do
156
271
  @failure_count.increment(1)
157
- if @failure_count.value >= @cb_threshold || @state == :half_open
158
- @state = :open
159
- @last_state_change = monotonic_time
272
+ if @failure_count.value >= @cb_threshold || @state_holder[:state] == :half_open
273
+ @state_holder[:state] = :open
274
+ @state_holder[:last_state_change] = monotonic_time
275
+ @state_holder[:probe_in_flight] = false
160
276
  end
161
277
  end
162
278
  end
163
279
 
280
+ def release_half_open_probe
281
+ @state_mutex.synchronize do
282
+ @state_holder[:probe_in_flight] = false
283
+ end
284
+ end
285
+
286
+ def transition_to_half_open_if_ready
287
+ return unless @state_holder[:state] == :open
288
+ return unless monotonic_time - @state_holder[:last_state_change] > @cb_timeout
289
+
290
+ @state_holder[:state] = :half_open
291
+ @state_holder[:last_state_change] = monotonic_time
292
+ @state_holder[:probe_in_flight] = false
293
+ end
294
+
164
295
  def monotonic_time
165
296
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
166
297
  end
298
+
299
+ def make_shareable_factory(factory)
300
+ Ractor.make_shareable(factory)
301
+ rescue Ractor::Error, TypeError => e
302
+ raise ArgumentError, "resource factory must be Ractor-shareable: #{e.message}"
303
+ end
167
304
  end
305
+ # rubocop:enable Metrics/MethodLength
306
+ # rubocop:enable Metrics/ClassLength
168
307
  end
169
308
  end
@@ -14,6 +14,7 @@ module Sidekiq
14
14
 
15
15
  attr_reader pool_name: Symbol
16
16
  attr_reader size: Integer
17
+ attr_reader pool_timeout: Numeric?
17
18
  attr_reader max_retries: Integer
18
19
  attr_reader retry_delay: untyped
19
20
  attr_reader validator: ^(untyped) -> bool
@@ -22,8 +23,10 @@ module Sidekiq
22
23
  attr_reader retryable_errors: Array[Class]
23
24
 
24
25
  def initialize: (
25
- pool_name: Symbol | String,
26
+ ?untyped options,
27
+ ?pool_name: (Symbol | String),
26
28
  ?size: Integer,
29
+ ?pool_timeout: Numeric?,
27
30
  ?max_retries: Integer,
28
31
  ?retry_delay: Numeric,
29
32
  ?cb_threshold: Integer,
@@ -34,12 +37,22 @@ module Sidekiq
34
37
  ) { () -> untyped } -> void
35
38
 
36
39
  def call: (untyped, untyped, untyped) { () -> untyped } -> untyped
40
+ def config=: (untyped) -> untyped
37
41
  def close: () -> nil
38
42
  def shutdown: () -> nil
39
43
  def with: () { (untyped) -> untyped } -> untyped
40
44
  def state: () -> Symbol
41
45
  private
42
- def validate_options!: (untyped, untyped, untyped, untyped, untyped) -> void
46
+ def validate_options!: (
47
+ size: untyped,
48
+ pool_timeout: untyped,
49
+ max_retries: untyped,
50
+ retry_delay: untyped,
51
+ cb_threshold: untyped,
52
+ cb_timeout: untyped
53
+ ) -> void
54
+ def normalize_options!: (untyped) { (Hash[Symbol, untyped]) -> void } -> void
55
+ def adopt_runtime: (instance) -> void
43
56
  def check_circuit_state!: () -> void
44
57
  def verify_health: (untyped) -> bool
45
58
  def retryable_error?: (StandardError) -> bool
@@ -47,7 +60,10 @@ module Sidekiq
47
60
  def default_validator: (untyped) -> bool
48
61
  def record_success: () -> void
49
62
  def record_failure: () -> void
63
+ def release_half_open_probe: () -> void
64
+ def transition_to_half_open_if_ready: () -> void
50
65
  def monotonic_time: () -> Float
66
+ def make_shareable_factory: (untyped) -> untyped
51
67
  end
52
68
  end
53
69
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sidekiq'
4
+ require 'redis-client'
5
+ require_relative 'worker'
6
+
7
+ redis_url = ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379/0').freeze
8
+ job_count = Integer(ENV.fetch('BENCHMARK_JOB_COUNT', '100'))
9
+ timeout = Integer(ENV.fetch('BENCHMARK_TIMEOUT', '120'))
10
+ run_id = ENV.fetch('BENCHMARK_RUN_ID', Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s)
11
+ redis = RedisClient.config(url: redis_url).new_client
12
+ Sidekiq.configure_client { |config| config.redis = { url: redis_url } }
13
+
14
+ redis.call('DEL', SmokeBenchmark::COUNT_KEY, SmokeBenchmark::RESULTS_KEY)
15
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
+ job_ids = job_count.times.map do |index|
17
+ job_id = "#{run_id}-#{index}"
18
+ Sidekiq::Client.push('class' => SmokeBenchmark::RedisBenchmarkWorker, 'args' => [job_id], 'queue' => 'benchmark')
19
+ job_id
20
+ end
21
+
22
+ deadline = started_at + timeout
23
+ loop do
24
+ processed = Integer(redis.call('GET', SmokeBenchmark::COUNT_KEY) || 0)
25
+ results = Integer(redis.call('HLEN', SmokeBenchmark::RESULTS_KEY) || 0)
26
+ break if processed >= job_count && results >= job_count
27
+
28
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
29
+ abort "Timed out waiting for #{job_count} jobs (processed=#{processed}, results=#{results})"
30
+ end
31
+
32
+ sleep 0.1
33
+ end
34
+
35
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
36
+ missing = job_ids.reject { |job_id| redis.call('HGET', SmokeBenchmark::RESULTS_KEY, job_id) == 'processed' }
37
+ abort "Missing processed jobs: #{missing.join(', ')}" unless missing.empty?
38
+
39
+ puts format('Benchmark passed: %<jobs>d jobs in %<seconds>.3f seconds (%<rate>.2f jobs/sec)',
40
+ jobs: job_count, seconds: elapsed, rate: job_count / elapsed)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'connection_pool'
4
+ require 'redis-client'
5
+ require 'sidekiq'
6
+ require_relative 'worker'
7
+
8
+ module SmokeBenchmark
9
+ # Connection-pool adapter with the same Redis health probe as the Ratomic run.
10
+ class ValidatedConnectionPool
11
+ def initialize(url:, size:, timeout:)
12
+ @pool = ConnectionPool.new(size:, timeout:) do
13
+ RedisClient.config(url:).new_client
14
+ end
15
+ end
16
+
17
+ def with
18
+ @pool.with do |redis|
19
+ raise 'Redis health check failed' unless redis.call('PING') == 'PONG'
20
+
21
+ yield redis
22
+ end
23
+ end
24
+ end
25
+
26
+ # Sidekiq middleware that injects the connection_pool-backed resource wrapper.
27
+ class ConnectionPoolBenchmarkMiddleware
28
+ def initialize(options = nil, pool_name: nil, size: 4, timeout: 5, redis_url: nil)
29
+ options ||= {}
30
+ pool_name = options.fetch(:pool_name, pool_name)
31
+ size = options.fetch(:size, size)
32
+ timeout = options.fetch(:timeout, timeout)
33
+ redis_url = options.fetch(:redis_url, redis_url)
34
+ @pool_name = pool_name
35
+ @pool = ValidatedConnectionPool.new(url: redis_url, size:, timeout:)
36
+ end
37
+
38
+ def config=(config)
39
+ mutex = config.instance_variable_get(:@connection_pool_benchmark_mutex)
40
+ unless mutex
41
+ mutex = Mutex.new
42
+ config.instance_variable_set(:@connection_pool_benchmark_mutex, mutex)
43
+ end
44
+
45
+ runtimes = config.instance_variable_get(:@connection_pool_benchmark_runtimes)
46
+ unless runtimes
47
+ runtimes = {}
48
+ config.instance_variable_set(:@connection_pool_benchmark_runtimes, runtimes)
49
+ end
50
+
51
+ mutex.synchronize do
52
+ runtime = runtimes[@pool_name]
53
+ if runtime
54
+ @pool = runtime.instance_variable_get(:@pool)
55
+ else
56
+ runtimes[@pool_name] = self
57
+ end
58
+ end
59
+ @config = config
60
+ end
61
+
62
+ def call(job, _payload, _queue)
63
+ setter = "#{@pool_name}="
64
+ job.public_send(setter, @pool) if job.respond_to?(setter)
65
+ yield
66
+ end
67
+ end
68
+ end
69
+
70
+ redis_port = ENV.fetch('REDIS_PORT', '6379')
71
+ redis_url = ENV.fetch('REDIS_URL', "redis://127.0.0.1:#{redis_port}/0").freeze
72
+
73
+ Sidekiq.configure_server do |config|
74
+ config.redis = { url: redis_url }
75
+ config.server_middleware do |chain|
76
+ chain.add(
77
+ SmokeBenchmark::ConnectionPoolBenchmarkMiddleware,
78
+ pool_name: :redis_pool,
79
+ size: Integer(ENV.fetch('RATOMIC_POOL_SIZE', '4')),
80
+ timeout: Float(ENV.fetch('RATOMIC_POOL_TIMEOUT', '1')),
81
+ redis_url:
82
+ )
83
+ end
84
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Host-owned Ractor integration example using real Redis. The host creates and
4
+ # joins Ractors; each Ractor constructs its own middleware runtime and owns its
5
+ # Redis connection pool.
6
+
7
+ require 'etc'
8
+ require 'redis-client'
9
+ require 'sidekiq'
10
+ require 'sidekiq/ratomic/pool'
11
+
12
+ RedisResource = Data.define(:ractor_index, :client) do
13
+ def call(...)
14
+ client.call(...)
15
+ end
16
+ end
17
+ RedisFactory = Data.define(:ractor_index, :url) do
18
+ def call
19
+ RedisResource.new(ractor_index, RedisClient.config(url:).new_client)
20
+ end
21
+ end
22
+ HostInput = Data.define(
23
+ :index, :threads, :jobs, :pool_size, :pool_timeout, :queue_capacity, :work_seconds,
24
+ :redis_url, :count_key, :results_key, :run_id
25
+ )
26
+
27
+ ractor_count = Integer(ENV.fetch('RACTOR_NATIVE_COUNT', Etc.nprocessors.to_s))
28
+ threads_per_ractor = Integer(ENV.fetch('RACTOR_NATIVE_THREADS', '20'))
29
+ pool_size = Integer(ENV.fetch('RATOMIC_POOL_SIZE', '20'))
30
+ pool_timeout = Float(ENV.fetch('RATOMIC_POOL_TIMEOUT', '1'))
31
+ queue_capacity = Integer(ENV.fetch('RACTOR_QUEUE_CAPACITY', (threads_per_ractor * 2).to_s))
32
+ jobs_per_ractor = Integer(ENV.fetch('BENCHMARK_JOBS_PER_RACTOR', threads_per_ractor.to_s))
33
+ work_seconds = Float(ENV.fetch('BENCHMARK_WORK_SECONDS', '0.05'))
34
+ redis_url = ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6380/0').freeze
35
+ run_id = ENV.fetch('BENCHMARK_RUN_ID', Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s).freeze
36
+ count_key = "sidekiq-ratomic-pool:host-ractor-benchmark:#{run_id}:processed".freeze
37
+ results_key = "sidekiq-ratomic-pool:host-ractor-benchmark:#{run_id}:results".freeze
38
+
39
+ configuration = [
40
+ [ractor_count, 'RACTOR_NATIVE_COUNT'],
41
+ [threads_per_ractor, 'RACTOR_NATIVE_THREADS'],
42
+ [pool_size, 'RATOMIC_POOL_SIZE'],
43
+ [queue_capacity, 'RACTOR_QUEUE_CAPACITY'],
44
+ [jobs_per_ractor, 'BENCHMARK_JOBS_PER_RACTOR']
45
+ ]
46
+ configuration.each do |value, name|
47
+ raise ArgumentError, "#{name} must be positive" unless value.positive?
48
+ end
49
+ raise ArgumentError, 'RATOMIC_POOL_TIMEOUT must be non-negative' if pool_timeout.negative?
50
+ raise ArgumentError, 'BENCHMARK_WORK_SECONDS must be non-negative' if work_seconds.negative?
51
+
52
+ redis = RedisClient.config(url: redis_url).new_client
53
+ redis.call('DEL', count_key, results_key)
54
+ redis_info = redis.call('INFO', 'server')
55
+ redis_version = redis_info.lines.filter_map do |line|
56
+ line.split(':', 2).last&.strip if line.start_with?('redis_version:')
57
+ end.first || 'unknown'
58
+
59
+ puts "Ruby: #{RUBY_DESCRIPTION}"
60
+ puts "Sidekiq: #{Sidekiq::VERSION}"
61
+ puts "Ratomic: #{Ratomic::VERSION}"
62
+ puts "Redis: #{redis_version}"
63
+ puts "Run ID: #{run_id}"
64
+ puts "CPU cores visible to the benchmark: #{Etc.nprocessors}"
65
+ puts "Redis URL: #{redis_url}"
66
+ puts "Ractors: #{ractor_count}, threads per Ractor: #{threads_per_ractor}, " \
67
+ "pool size per Ractor: #{pool_size}, checkout timeout: #{pool_timeout}s"
68
+ puts "Jobs: #{ractor_count * jobs_per_ractor}, work per job: #{work_seconds}s, " \
69
+ "queue capacity per Ractor: #{queue_capacity}"
70
+
71
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+ ractors = ractor_count.times.map do |index| # rubocop:disable Metrics/BlockLength
73
+ input = Ractor.make_shareable(
74
+ HostInput.new(
75
+ index, threads_per_ractor, jobs_per_ractor, pool_size, pool_timeout, queue_capacity, work_seconds,
76
+ redis_url, count_key, results_key, run_id
77
+ )
78
+ )
79
+ Ractor.new(input) do |ractor_input| # rubocop:disable Metrics/BlockLength
80
+ factory = RedisFactory.new(ractor_input.index, ractor_input.redis_url)
81
+ validator = ->(resource) { resource.call('PING') == 'PONG' }
82
+ pool = Sidekiq::Ratomic::Pool.new(
83
+ pool_name: :redis_pool,
84
+ size: ractor_input.pool_size,
85
+ pool_timeout: ractor_input.pool_timeout,
86
+ validator:,
87
+ factory:
88
+ )
89
+ jobs = SizedQueue.new(ractor_input.queue_capacity)
90
+ metrics = { active: 0, max_active: 0, processed: 0, error: nil }
91
+ metrics_mutex = Mutex.new
92
+ cancelled = false
93
+
94
+ producer = Thread.new do
95
+ ractor_input.jobs.times do |job|
96
+ break if cancelled
97
+
98
+ jobs << job
99
+ end
100
+ rescue ClosedQueueError
101
+ nil
102
+ ensure
103
+ jobs.close
104
+ end
105
+
106
+ workers = ractor_input.threads.times.map do # rubocop:disable Metrics/BlockLength
107
+ Thread.new do
108
+ loop do
109
+ job = jobs.pop
110
+ break if job.nil? || cancelled
111
+
112
+ metrics_mutex.synchronize do
113
+ metrics[:active] += 1
114
+ metrics[:max_active] = [metrics[:max_active], metrics[:active]].max
115
+ end
116
+ begin
117
+ pool.with do |resource|
118
+ job_id = "#{ractor_input.run_id}-#{ractor_input.index}-#{job}"
119
+ resource.call('INCR', ractor_input.count_key)
120
+ resource.call('HSET', ractor_input.results_key, job_id, 'processed')
121
+ sleep ractor_input.work_seconds if ractor_input.work_seconds.positive?
122
+ end
123
+ metrics_mutex.synchronize { metrics[:processed] += 1 }
124
+ rescue StandardError => e
125
+ metrics_mutex.synchronize { metrics[:error] = "#{e.class}: #{e.message}" }
126
+ cancelled = true
127
+ jobs.close
128
+ ensure
129
+ metrics_mutex.synchronize { metrics[:active] -= 1 }
130
+ end
131
+ end
132
+ rescue ClosedQueueError
133
+ nil
134
+ end
135
+ end
136
+
137
+ workers.each(&:value)
138
+ producer.join
139
+ pool.close
140
+ metrics.merge(index: ractor_input.index, jobs: ractor_input.jobs, threads: ractor_input.threads)
141
+ end
142
+ end
143
+
144
+ results = ractors.map(&:value)
145
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
146
+ failures = results.filter_map { |result| result[:error] }
147
+ processed = Integer(redis.call('GET', count_key) || 0)
148
+ result_count = Integer(redis.call('HLEN', results_key) || 0)
149
+ expected = ractor_count * jobs_per_ractor
150
+ unless failures.empty? && processed == expected && result_count == expected
151
+ abort "Host-owned Ractor example failed: results=#{results.inspect}, processed=#{processed}, " \
152
+ "result_count=#{result_count}, expected=#{expected}"
153
+ end
154
+
155
+ puts format(
156
+ 'Host-owned Ractor example passed: %<ractors>d Ractors × %<pool>d resources × %<threads>d threads, ' \
157
+ '%<jobs>d jobs in %<seconds>.3f seconds (%<rate>.2f jobs/sec), max active=%<active>d',
158
+ ractors: ractor_count, pool: pool_size, threads: threads_per_ractor, jobs: processed,
159
+ seconds: elapsed, rate: processed / elapsed, active: results.sum { |result| result[:max_active] }
160
+ )
161
+ puts "Per-Ractor results: #{results.inspect}"
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ratomic'
4
+ require 'redis-client'
5
+
6
+ RedisFactory = Data.define(:url) do
7
+ def call
8
+ RedisClient.config(url:).new_client
9
+ end
10
+ end
11
+ RactorInput = Data.define(:pool, :jobs, :threads, :work_seconds, :count_key, :results_key, :run_id, :index)
12
+
13
+ ractor_count = Integer(ENV.fetch('RACTOR_NATIVE_COUNT', '4'))
14
+ threads_per_ractor = Integer(ENV.fetch('RACTOR_NATIVE_THREADS', '20'))
15
+ pool_size = Integer(ENV.fetch('RATOMIC_POOL_SIZE', '20'))
16
+ pool_timeout = Float(ENV.fetch('RATOMIC_POOL_TIMEOUT', '1'))
17
+ work_seconds = Float(ENV.fetch('BENCHMARK_WORK_SECONDS', '0.05'))
18
+ job_count = Integer(ENV.fetch('BENCHMARK_JOB_COUNT', (ractor_count * threads_per_ractor).to_s))
19
+
20
+ redis_url = ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379/0').freeze
21
+ run_id = ENV.fetch('BENCHMARK_RUN_ID', Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s).freeze
22
+ count_key = "sidekiq-ratomic-pool:ractor-benchmark:#{run_id}:processed".freeze
23
+ results_key = "sidekiq-ratomic-pool:ractor-benchmark:#{run_id}:results".freeze
24
+ redis = RedisClient.config(url: redis_url).new_client
25
+ redis.call('DEL', count_key, results_key)
26
+
27
+ factory = Ractor.make_shareable(RedisFactory.new(redis_url))
28
+ pool = Ratomic::LocalPool.new(size: pool_size, timeout: pool_timeout, factory:)
29
+ jobs_per_ractor = job_count / ractor_count
30
+ unless jobs_per_ractor * ractor_count == job_count
31
+ raise ArgumentError, 'BENCHMARK_JOB_COUNT must divide evenly across Ractors'
32
+ end
33
+
34
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ ractors = ractor_count.times.map do |ractor_index| # rubocop:disable Metrics/BlockLength
36
+ input = Ractor.make_shareable(
37
+ RactorInput.new(
38
+ pool, jobs_per_ractor, threads_per_ractor, work_seconds, count_key, results_key, run_id, ractor_index
39
+ )
40
+ )
41
+ Ractor.new(input) do |ractor_input| # rubocop:disable Metrics/BlockLength
42
+ local_pool = ractor_input.pool
43
+ local_jobs = ractor_input.jobs
44
+ local_thread_count = ractor_input.threads
45
+ local_work_seconds = ractor_input.work_seconds
46
+ processed_key = ractor_input.count_key
47
+ result_key = ractor_input.results_key
48
+ benchmark_run_id = ractor_input.run_id
49
+ index = ractor_input.index
50
+ jobs = Queue.new
51
+ local_jobs.times { |job_index| jobs << job_index }
52
+ threads = local_thread_count.times.map do
53
+ Thread.new do
54
+ loop do
55
+ job_index = begin
56
+ jobs.pop(true)
57
+ rescue ThreadError
58
+ break
59
+ end
60
+ job_id = "#{benchmark_run_id}-#{index}-#{job_index}"
61
+ local_pool.with do |redis_client|
62
+ raise 'Redis health check failed' unless redis_client.call('PING') == 'PONG'
63
+
64
+ redis_client.call('INCR', processed_key)
65
+ redis_client.call('HSET', result_key, job_id, 'processed')
66
+ sleep local_work_seconds if local_work_seconds.positive?
67
+ end
68
+ end
69
+ end
70
+ end
71
+ threads.each(&:value)
72
+ local_pool.close
73
+ [local_thread_count, local_jobs]
74
+ end
75
+ end
76
+
77
+ ractor_results = ractors.map(&:value)
78
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
79
+ processed = Integer(redis.call('GET', count_key) || 0)
80
+ results = Integer(redis.call('HLEN', results_key) || 0)
81
+ abort "Benchmark mismatch (processed=#{processed}, results=#{results}, expected=#{job_count})" unless
82
+ processed == job_count && results == job_count
83
+
84
+ puts format(
85
+ 'Ractor benchmark passed: %<ractors>d Ractors × %<pool>d resources × %<threads>d threads, ' \
86
+ '%<jobs>d jobs in %<seconds>.3f seconds (%<rate>.2f jobs/sec)',
87
+ ractors: ractor_results.size, pool: pool_size, threads: threads_per_ractor, jobs: job_count,
88
+ seconds: elapsed, rate: job_count / elapsed
89
+ )
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../server'
4
+ require_relative 'worker'
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmokeBenchmark
4
+ COUNT_KEY = 'sidekiq-ratomic-pool:benchmark:processed'
5
+ RESULTS_KEY = 'sidekiq-ratomic-pool:benchmark:results'
6
+
7
+ # Benchmark job that performs Redis work through the injected pool.
8
+ class RedisBenchmarkWorker
9
+ include Sidekiq::Job
10
+
11
+ sidekiq_options queue: 'benchmark', retry: 0
12
+
13
+ attr_accessor :redis_pool
14
+
15
+ def perform(job_id)
16
+ redis_pool.with do |redis|
17
+ redis.call('INCR', COUNT_KEY)
18
+ redis.call('HSET', RESULTS_KEY, job_id, 'processed')
19
+ work_seconds = Float(ENV.fetch('BENCHMARK_WORK_SECONDS', '0.05'))
20
+ sleep work_seconds if work_seconds.positive?
21
+ end
22
+ end
23
+ end
24
+ end
data/smoke_test/server.rb CHANGED
@@ -21,6 +21,7 @@ Sidekiq.configure_server do |config|
21
21
  Sidekiq::Ratomic::Pool,
22
22
  pool_name: :redis_pool,
23
23
  size: Integer(ENV.fetch('RATOMIC_POOL_SIZE', '4')),
24
+ pool_timeout: Float(ENV.fetch('RATOMIC_POOL_TIMEOUT', '1')),
24
25
  max_retries: 3,
25
26
  retry_delay: 0.05,
26
27
  cb_threshold: 5,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sidekiq-ratomic-pool
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -40,6 +40,12 @@ files:
40
40
  - lib/sidekiq/ratomic/pool/version.rb
41
41
  - lib/sidekiq_ratomic_pool.rb
42
42
  - sig/sidekiq/ratomic/pool.rbs
43
+ - smoke_test/benchmark/client.rb
44
+ - smoke_test/benchmark/connection_pool_server.rb
45
+ - smoke_test/benchmark/ractor_host_adapter.rb
46
+ - smoke_test/benchmark/ractor_native.rb
47
+ - smoke_test/benchmark/server.rb
48
+ - smoke_test/benchmark/worker.rb
43
49
  - smoke_test/client.rb
44
50
  - smoke_test/server.rb
45
51
  - smoke_test/worker.rb
@@ -66,7 +72,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
66
72
  - !ruby/object:Gem::Version
67
73
  version: '0'
68
74
  requirements: []
69
- rubygems_version: 4.0.18
75
+ rubygems_version: 4.0.16
70
76
  specification_version: 4
71
77
  summary: Ractor-safe connection pooling for Sidekiq utilizing Ratomic's LocalPool.
72
78
  test_files: []