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 +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +83 -1
- data/lib/sidekiq/ratomic/pool/version.rb +1 -1
- data/lib/sidekiq/ratomic/pool.rb +98 -19
- data/sig/sidekiq/ratomic/pool.rbs +13 -1
- data/smoke_test/benchmark/client.rb +40 -0
- data/smoke_test/benchmark/connection_pool_server.rb +84 -0
- data/smoke_test/benchmark/ractor_host_adapter.rb +161 -0
- data/smoke_test/benchmark/ractor_native.rb +89 -0
- data/smoke_test/benchmark/server.rb +4 -0
- data/smoke_test/benchmark/worker.rb +24 -0
- data/smoke_test/server.rb +1 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6497422ce457ce3e5aab57c5e1d7de44aec7b7a6f2784e83a58c0a8419f8b19
|
|
4
|
+
data.tar.gz: 36f559671bcf385abe58c0d98d9762eb952d97b70faa8ec4830a730106aa03b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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**:
|
|
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.
|
data/lib/sidekiq/ratomic/pool.rb
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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!(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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!: (
|
|
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,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.
|
|
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
|