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 +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +86 -4
- data/lib/sidekiq/ratomic/pool/version.rb +1 -1
- data/lib/sidekiq/ratomic/pool.rb +161 -22
- data/sig/sidekiq/ratomic/pool.rbs +18 -2
- 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 +8 -2
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,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**:
|
|
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
|
|
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.
|
|
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.
|
data/lib/sidekiq/ratomic/pool.rb
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
18
|
+
# Name of the worker accessor populated by the middleware.
|
|
19
|
+
# @return [Symbol]
|
|
20
|
+
attr_reader :pool_name
|
|
19
21
|
|
|
20
|
-
|
|
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!(
|
|
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
|
-
@
|
|
39
|
-
@last_state_change = monotonic_time
|
|
90
|
+
@state_holder = { state: :closed, last_state_change: monotonic_time, probe_in_flight: false }
|
|
40
91
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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!: (
|
|
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,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
|
|
@@ -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.
|
|
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: []
|