sidekiq-ratomic-pool 0.2.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: 3c1be6120972266a46da7e3b1646f695b015be76d366c231374d47d93aae1ac6
4
- data.tar.gz: 85b6f68ea11bcc778afd3874551577b364bda3af6b5e8e9084c9e249ee18f85b
3
+ metadata.gz: f6497422ce457ce3e5aab57c5e1d7de44aec7b7a6f2784e83a58c0a8419f8b19
4
+ data.tar.gz: 36f559671bcf385abe58c0d98d9762eb952d97b70faa8ec4830a730106aa03b1
5
5
  SHA512:
6
- metadata.gz: 065052f3df563140c3928c37d4c4953ab4c1297760aa046ec6c91cd41e90b6debefa4aea28b13399975c4556b50a59e986127cd70bf4f060652a6579cdc13f5b
7
- data.tar.gz: a045612004cdd32f615e4dfbb4eacab6240257d06ae68ecaa02d322a479933d24c1f1a202339effc8d61c07f9b9502ccf175adcc337d6e19918e3b1382a52605
6
+ metadata.gz: 79fef0ed157daa34e338156639ae19346118ab2f72b284d242da0f592ee6910ec19f33433ed6cec0044e1303df633ae47f1af21d2d5c13d943b7015a10ae8e42
7
+ data.tar.gz: e3ea3f1ed5016670de28fcca67629a599e8ef491915114aa3bff5509920d9e97e3b257380c3bba6e18d178c621d2f916d502b04836743bb533b35c77a527aca3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
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
+
3
42
  ## [0.2.0] - 2026-08-25
4
43
 
5
44
  - Added a real Redis-backed `smoke_test/` harness with standalone Sidekiq client
data/README.md CHANGED
@@ -27,11 +27,14 @@ 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
 
@@ -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.2.0'
9
+ VERSION = '0.3.0'
10
10
  end
11
11
  end
12
12
  end
@@ -15,16 +15,50 @@ module Sidekiq
15
15
  # with exponential backoff. Persistent failures open the circuit breaker.
16
16
  # rubocop:disable Metrics/ClassLength
17
17
  class Pool
18
- attr_reader :pool_name, :size, :max_retries, :retry_delay, :validator, :cb_threshold, :cb_timeout,
19
- :retryable_errors
18
+ # Name of the worker accessor populated by the middleware.
19
+ # @return [Symbol]
20
+ attr_reader :pool_name
21
+
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
20
53
 
21
54
  # rubocop:disable Metrics/MethodLength
22
- def initialize(options = nil, pool_name: nil, size: 10, max_retries: 3, retry_delay: 0.2,
55
+ def initialize(options = nil, pool_name: nil, size: 10, pool_timeout: 1.0, max_retries: 3, retry_delay: 0.2,
23
56
  cb_threshold: 5, cb_timeout: 30, validator: nil,
24
57
  retryable_errors: [IOError, SystemCallError, Timeout::Error], factory: nil, &block)
25
58
  normalize_options!(options) do |config|
26
59
  pool_name = config.fetch(:pool_name, pool_name)
27
60
  size = config.fetch(:size, size)
61
+ pool_timeout = config.fetch(:pool_timeout, pool_timeout)
28
62
  max_retries = config.fetch(:max_retries, max_retries)
29
63
  retry_delay = config.fetch(:retry_delay, retry_delay)
30
64
  cb_threshold = config.fetch(:cb_threshold, cb_threshold)
@@ -38,10 +72,13 @@ module Sidekiq
38
72
  raise ArgumentError, 'A pool_name must be provided' unless pool_name
39
73
  raise ArgumentError, 'A resource factory must be provided' unless factory
40
74
 
41
- 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
+ )
42
78
 
43
79
  @pool_name = pool_name.to_sym
44
80
  @size = size
81
+ @pool_timeout = pool_timeout
45
82
  @max_retries = max_retries
46
83
  @retry_delay = retry_delay
47
84
  @cb_threshold = cb_threshold
@@ -50,10 +87,12 @@ module Sidekiq
50
87
  @retryable_errors = retryable_errors.freeze
51
88
  @state_mutex = Mutex.new
52
89
  @failure_count = ::Ratomic::Counter.new
53
- @state_holder = { state: :closed, last_state_change: monotonic_time }
90
+ @state_holder = { state: :closed, last_state_change: monotonic_time, probe_in_flight: false }
91
+
92
+ raise ArgumentError, 'validator must respond to call' unless validator.nil? || validator.respond_to?(:call)
54
93
 
55
- shareable_factory = Ractor.make_shareable(factory)
56
- @local_pool = ::Ratomic::LocalPool.new(size: @size, factory: shareable_factory)
94
+ shareable_factory = make_shareable_factory(factory)
95
+ @local_pool = ::Ratomic::LocalPool.new(size: @size, timeout: @pool_timeout, factory: shareable_factory)
57
96
  end
58
97
 
59
98
  # Share one pool runtime across Sidekiq's per-job middleware instances.
@@ -97,7 +136,7 @@ module Sidekiq
97
136
 
98
137
  # Check out a healthy resource and yield it to the caller.
99
138
  def with
100
- check_circuit_state!
139
+ probe_reserved = check_circuit_state!
101
140
  attempts = 0
102
141
  work_failed = false
103
142
 
@@ -116,7 +155,10 @@ module Sidekiq
116
155
  result
117
156
  end
118
157
  rescue StandardError => e
119
- 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
120
162
 
121
163
  record_failure
122
164
  if attempts <= @max_retries && state != :open
@@ -132,10 +174,7 @@ module Sidekiq
132
174
  # Return the current circuit-breaker state.
133
175
  def state
134
176
  @state_mutex.synchronize do
135
- if @state_holder[:state] == :open && monotonic_time - @state_holder[:last_state_change] > @cb_timeout
136
- @state_holder[:state] = :half_open
137
- @state_holder[:last_state_change] = monotonic_time
138
- end
177
+ transition_to_half_open_if_ready
139
178
  @state_holder[:state]
140
179
  end
141
180
  end
@@ -143,9 +182,20 @@ module Sidekiq
143
182
  private
144
183
 
145
184
  def check_circuit_state!
146
- return unless state == :open
147
-
148
- raise Pool::CircuitOpenError, 'Circuit breaker is 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]
192
+
193
+ @state_holder[:probe_in_flight] = true
194
+ true
195
+ else
196
+ false
197
+ end
198
+ end
149
199
  end
150
200
 
151
201
  def verify_health(resource)
@@ -162,9 +212,11 @@ module Sidekiq
162
212
  error.is_a?(StandardError) && !error.is_a?(Pool::CheckoutError)
163
213
  end
164
214
 
165
- 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:)
166
216
  validations = [
167
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'],
168
220
  [max_retries.is_a?(Integer) && max_retries >= 0, 'max_retries must be a non-negative Integer'],
169
221
  [retry_delay.is_a?(Numeric) && retry_delay >= 0, 'retry_delay must be non-negative'],
170
222
  [cb_threshold.is_a?(Integer) && cb_threshold.positive?, 'cb_threshold must be a positive Integer'],
@@ -180,7 +232,8 @@ module Sidekiq
180
232
  raise ArgumentError, 'middleware options must be a Hash' unless options.is_a?(Hash)
181
233
 
182
234
  config = options.transform_keys(&:to_sym)
183
- allowed = %i[pool_name size max_retries retry_delay cb_threshold cb_timeout validator retryable_errors factory]
235
+ allowed = %i[pool_name size pool_timeout max_retries retry_delay cb_threshold cb_timeout validator
236
+ retryable_errors factory]
184
237
  unknown = config.keys - allowed
185
238
  raise ArgumentError, "unknown middleware options: #{unknown.join(', ')}" unless unknown.empty?
186
239
 
@@ -189,6 +242,7 @@ module Sidekiq
189
242
 
190
243
  def adopt_runtime(runtime)
191
244
  @local_pool = runtime.instance_variable_get(:@local_pool)
245
+ @pool_timeout = runtime.instance_variable_get(:@pool_timeout)
192
246
  @state_mutex = runtime.instance_variable_get(:@state_mutex)
193
247
  @failure_count = runtime.instance_variable_get(:@failure_count)
194
248
  @state_holder = runtime.instance_variable_get(:@state_holder)
@@ -205,7 +259,10 @@ module Sidekiq
205
259
  @state_mutex.synchronize do
206
260
  failure_count = @failure_count.value
207
261
  @failure_count.decrement(failure_count) unless failure_count.zero?
208
- @state_holder[:state] = :closed if @state_holder[:state] == :half_open
262
+ if @state_holder[:state] == :half_open
263
+ @state_holder[:state] = :closed
264
+ @state_holder[:probe_in_flight] = false
265
+ end
209
266
  end
210
267
  end
211
268
 
@@ -215,13 +272,35 @@ module Sidekiq
215
272
  if @failure_count.value >= @cb_threshold || @state_holder[:state] == :half_open
216
273
  @state_holder[:state] = :open
217
274
  @state_holder[:last_state_change] = monotonic_time
275
+ @state_holder[:probe_in_flight] = false
218
276
  end
219
277
  end
220
278
  end
221
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
+
222
295
  def monotonic_time
223
296
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
224
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
225
304
  end
226
305
  # rubocop:enable Metrics/MethodLength
227
306
  # rubocop:enable Metrics/ClassLength
@@ -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
@@ -25,6 +26,7 @@ module Sidekiq
25
26
  ?untyped options,
26
27
  ?pool_name: (Symbol | String),
27
28
  ?size: Integer,
29
+ ?pool_timeout: Numeric?,
28
30
  ?max_retries: Integer,
29
31
  ?retry_delay: Numeric,
30
32
  ?cb_threshold: Integer,
@@ -41,7 +43,14 @@ module Sidekiq
41
43
  def with: () { (untyped) -> untyped } -> untyped
42
44
  def state: () -> Symbol
43
45
  private
44
- 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
45
54
  def normalize_options!: (untyped) { (Hash[Symbol, untyped]) -> void } -> void
46
55
  def adopt_runtime: (instance) -> void
47
56
  def check_circuit_state!: () -> void
@@ -51,7 +60,10 @@ module Sidekiq
51
60
  def default_validator: (untyped) -> bool
52
61
  def record_success: () -> void
53
62
  def record_failure: () -> void
63
+ def release_half_open_probe: () -> void
64
+ def transition_to_half_open_if_ready: () -> void
54
65
  def monotonic_time: () -> Float
66
+ def make_shareable_factory: (untyped) -> untyped
55
67
  end
56
68
  end
57
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.2.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