sidekiq-ratomic-pool 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 73e5869196bd322f91cea20c1332e39b2c2e3c9d81e99717ac74210fb766be36
4
+ data.tar.gz: 4b830bdc58727ba25cc653011bf52d1394d2344ad5de996a7f20965b9b404e57
5
+ SHA512:
6
+ metadata.gz: 6272ccc17b365724a851f3d6b39cb82d4ce109068cc854a640ab0b649f2c062bf4ee6afdeccf0c6e442789dfe03be411eb3bf8abd8dab2da36d8f9b5f8d09e00
7
+ data.tar.gz: 71f001b3aca75e43792bf760795d037a6f7a220eb15de33da7b1802eab89e976186ebce147f6eeb21f95e88c702ffd05c8309b06a410831609b58ff613133417
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ ## [Unreleased]
2
+
3
+ - Added Sidekiq middleware with Ratomic `LocalPool` resource injection,
4
+ health validation, exponential retries, and circuit-breaker fast failure.
5
+ - Added thread-boundary pool behavior tests and synchronized RBS signatures for the
6
+ Ractor-local implementation.
7
+ - Added the `quality` Rake task covering tests, RuboCop, Steep, and YARD validation.
8
+ - Enforced at least 99% line and branch coverage through SimpleCov.
9
+ - Used Ratomic's native `Counter` primitive.
10
+ - Added YARD validation and documented the public pool API.
11
+ - Added argument validation and current-Ractor pool shutdown.
12
+ - Added configurable retryable I/O errors and real exponential backoff delays.
13
+ - Prevented non-retryable worker exceptions from affecting the circuit breaker.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Kenneth C. Demanawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # sidekiq-ratomic-pool
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/sidekiq-ratomic-pool.svg)](https://badge.fury.io/rb/sidekiq-ratomic-pool)
4
+ [![CI](https://github.com/kanutocd/sidekiq-ratomic-pool/workflows/CI/badge.svg)](https://github.com/kanutocd/sidekiq-ratomic-pool/actions)
5
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%204.0-ruby.svg)](https://www.ruby-lang.org/en/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+
9
+ Sidekiq server middleware leveraging `Ratomic::LocalPool` for Ractor-local resource ownership. It includes automated connection health validation, exponential backoff retries, and an integrated **Circuit Breaker** to help prevent cascading resource failures.
10
+
11
+ ## Installation
12
+
13
+ Add to your `Gemfile`:
14
+
15
+ ```ruby
16
+ gem "sidekiq-ratomic-pool"
17
+ ```
18
+
19
+ ## Sidekiq dependency
20
+
21
+ Despite its name, this gem does not declare `sidekiq` as a runtime dependency.
22
+ It provides a `Sidekiq::Ratomic::Pool` middleware implementation using the
23
+ standard `call(job, payload, queue) { ... }` middleware contract. Sidekiq is the
24
+ primary supported integration and the reason for the gem name, but other
25
+ Sidekiq-compatible job frameworks can use it if they support the same contract
26
+ and worker pool accessor pattern.
27
+
28
+ ## Features
29
+
30
+ - **Ractor-Local Isolation**: Each Ractor lazily owns its resources through `Ratomic::LocalPool`; threads within the same Ractor share that Ractor-local pool.
31
+ - **Circuit Breaker Pattern**: Trips open after a configurable threshold of checkout, health-check, or configured retryable I/O failures.
32
+ - **Exponential Backoff**: Applies increasing retry delays to transient checkout and retryable resource-operation failures.
33
+ - **Automated Health Probes**: Validates resources with `ping`, `active?`, or a caller-supplied validator before use.
34
+ - **Configurable Failure Policy**: Non-retryable worker exceptions propagate without changing circuit state, avoiding accidental duplicate work.
35
+
36
+ ## Usage
37
+
38
+ Factories must be Ractor-shareable because `Ratomic::LocalPool` creates resources lazily
39
+ inside each Ractor. A small frozen factory object is suitable for production use:
40
+
41
+ ```ruby
42
+ RedisFactory = Data.define(:url) do
43
+ def call
44
+ RedisClient.new(url:)
45
+ end
46
+ end
47
+
48
+ Sidekiq.configure_server do |config|
49
+ config.server_middleware do |chain|
50
+ chain.add Sidekiq::Ratomic::Pool,
51
+ pool_name: :redis_pool,
52
+ size: 10,
53
+ max_retries: 3,
54
+ retry_delay: 0.2,
55
+ cb_threshold: 5,
56
+ cb_timeout: 30,
57
+ factory: RedisFactory.new(ENV.fetch('REDIS_URL')).freeze
58
+ end
59
+ end
60
+ ```
61
+
62
+ Workers use the injected pool with `with`:
63
+
64
+ ```ruby
65
+ redis_pool.with { |redis| redis.call('PING') }
66
+ ```
67
+
68
+ Resource checkout/health failures and configured retryable I/O errors use exponential backoff.
69
+ Other exceptions raised by the worker block propagate without being retried, preventing
70
+ accidental duplication of non-idempotent work.
71
+
72
+ ## Smoke test
73
+
74
+ The [`smoke_test/`](smoke_test/) harness runs Redis in Docker, starts a standalone
75
+ Sidekiq server, enqueues jobs from a separate client process, and verifies the
76
+ results through real Redis connections. It also prints `ps -L` snapshots showing
77
+ Sidekiq worker threads and the CPU core (`PSR`) on which they were recently scheduled:
78
+
79
+ ```bash
80
+ cd smoke_test
81
+ ./run.sh
82
+ ```
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sidekiq integration namespace.
4
+ module Sidekiq
5
+ # Ratomic-backed Sidekiq middleware namespace.
6
+ module Ratomic
7
+ class Pool
8
+ # Base error for pool failures.
9
+ class Error < StandardError; end
10
+
11
+ # Raised when a checked-out resource fails validation.
12
+ class CheckoutError < Error; end
13
+
14
+ # Raised when the circuit breaker is open.
15
+ class CircuitOpenError < Error; end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sidekiq integration namespace.
4
+ module Sidekiq
5
+ # Ratomic-backed Sidekiq middleware namespace.
6
+ module Ratomic
7
+ class Pool
8
+ # Current gem version.
9
+ VERSION = '0.1.0'
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ratomic'
4
+ require 'timeout'
5
+ require_relative 'pool/errors'
6
+ require_relative 'pool/version'
7
+
8
+ # Sidekiq integration namespace.
9
+ module Sidekiq
10
+ # Ratomic-backed Sidekiq middleware namespace.
11
+ module Ratomic
12
+ # Sidekiq server middleware that exposes a Ractor-local resource pool.
13
+ #
14
+ # Resources are validated before checkout and transient failures are retried
15
+ # with exponential backoff. Persistent failures open the circuit breaker.
16
+ class Pool
17
+ attr_reader :pool_name, :size, :max_retries, :retry_delay, :validator, :cb_threshold, :cb_timeout,
18
+ :retryable_errors
19
+
20
+ def initialize(pool_name:, size: 10, max_retries: 3, retry_delay: 0.2,
21
+ cb_threshold: 5, cb_timeout: 30, validator: nil,
22
+ retryable_errors: [IOError, SystemCallError, Timeout::Error], factory: nil, &block)
23
+ factory ||= block
24
+ raise ArgumentError, 'A resource factory must be provided' unless factory
25
+
26
+ validate_options!(size, max_retries, retry_delay, cb_threshold, cb_timeout)
27
+
28
+ @pool_name = pool_name.to_sym
29
+ @size = size
30
+ @max_retries = max_retries
31
+ @retry_delay = retry_delay
32
+ @cb_threshold = cb_threshold
33
+ @cb_timeout = cb_timeout
34
+ @validator = validator || method(:default_validator)
35
+ @retryable_errors = retryable_errors.freeze
36
+ @state_mutex = Mutex.new
37
+ @failure_count = ::Ratomic::Counter.new
38
+ @state = :closed
39
+ @last_state_change = monotonic_time
40
+
41
+ shareable_factory = Ractor.make_shareable(factory)
42
+ @local_pool = ::Ratomic::LocalPool.new(size: @size, factory: shareable_factory)
43
+ end
44
+
45
+ # Inject this pool into a worker's configured pool accessor.
46
+ def call(job_instance, _job_payload, _queue)
47
+ setter = "#{@pool_name}="
48
+ job_instance.public_send(setter, self) if job_instance.respond_to?(setter)
49
+ yield
50
+ end
51
+
52
+ # Close resources owned by the current Ractor.
53
+ def close
54
+ @local_pool.close
55
+ end
56
+
57
+ alias shutdown close
58
+
59
+ # Check out a healthy resource and yield it to the caller.
60
+ def with
61
+ check_circuit_state!
62
+ attempts = 0
63
+ work_failed = false
64
+
65
+ begin
66
+ attempts += 1
67
+ @local_pool.with do |resource|
68
+ raise Pool::CheckoutError, 'Resource connection health check failed' unless verify_health(resource)
69
+
70
+ begin
71
+ result = yield resource
72
+ rescue StandardError
73
+ work_failed = true
74
+ raise
75
+ end
76
+ record_success
77
+ result
78
+ end
79
+ rescue StandardError => e
80
+ raise unless !work_failed || retryable_error?(e)
81
+
82
+ record_failure
83
+ if attempts <= @max_retries && state != :open
84
+ delay = @retry_delay * (2**(attempts - 1))
85
+ sleep(delay) if delay.positive?
86
+ retry
87
+ end
88
+
89
+ raise
90
+ end
91
+ end
92
+
93
+ # Return the current circuit-breaker state.
94
+ def state
95
+ @state_mutex.synchronize do
96
+ if @state == :open && monotonic_time - @last_state_change > @cb_timeout
97
+ @state = :half_open
98
+ @last_state_change = monotonic_time
99
+ end
100
+ @state
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def check_circuit_state!
107
+ return unless state == :open
108
+
109
+ raise Pool::CircuitOpenError, 'Circuit breaker is open'
110
+ end
111
+
112
+ def verify_health(resource)
113
+ @validator.call(resource)
114
+ rescue StandardError
115
+ false
116
+ end
117
+
118
+ def retryable_error?(error)
119
+ !work_error?(error) || @retryable_errors.any? { |error_class| error.is_a?(error_class) }
120
+ end
121
+
122
+ def work_error?(error)
123
+ error.is_a?(StandardError) && !error.is_a?(Pool::CheckoutError)
124
+ end
125
+
126
+ def validate_options!(size, max_retries, retry_delay, cb_threshold, cb_timeout)
127
+ validations = [
128
+ [size.is_a?(Integer) && size.positive?, 'size must be a positive Integer'],
129
+ [max_retries.is_a?(Integer) && max_retries >= 0, 'max_retries must be a non-negative Integer'],
130
+ [retry_delay.is_a?(Numeric) && retry_delay >= 0, 'retry_delay must be non-negative'],
131
+ [cb_threshold.is_a?(Integer) && cb_threshold.positive?, 'cb_threshold must be a positive Integer'],
132
+ [cb_timeout.is_a?(Numeric) && cb_timeout >= 0, 'cb_timeout must be non-negative']
133
+ ]
134
+ validations.each do |valid, message|
135
+ raise ArgumentError, message unless valid
136
+ end
137
+ end
138
+
139
+ def default_validator(resource)
140
+ return resource.ping if resource.respond_to?(:ping)
141
+ return resource.active? if resource.respond_to?(:active?)
142
+
143
+ true
144
+ end
145
+
146
+ def record_success
147
+ @state_mutex.synchronize do
148
+ failure_count = @failure_count.value
149
+ @failure_count.decrement(failure_count) unless failure_count.zero?
150
+ @state = :closed if @state == :half_open
151
+ end
152
+ end
153
+
154
+ def record_failure
155
+ @state_mutex.synchronize do
156
+ @failure_count.increment(1)
157
+ if @failure_count.value >= @cb_threshold || @state == :half_open
158
+ @state = :open
159
+ @last_state_change = monotonic_time
160
+ end
161
+ end
162
+ end
163
+
164
+ def monotonic_time
165
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'sidekiq/ratomic/pool'
@@ -0,0 +1,62 @@
1
+ module Sidekiq
2
+ module Ratomic
3
+ class Pool
4
+ VERSION: String
5
+
6
+ class Error < StandardError
7
+ end
8
+
9
+ class CheckoutError < Error
10
+ end
11
+
12
+ class CircuitOpenError < Error
13
+ end
14
+
15
+ attr_reader pool_name: Symbol
16
+ attr_reader size: Integer
17
+ attr_reader max_retries: Integer
18
+ attr_reader retry_delay: untyped
19
+ attr_reader validator: ^(untyped) -> bool
20
+ attr_reader cb_threshold: Integer
21
+ attr_reader cb_timeout: Numeric
22
+ attr_reader retryable_errors: Array[Class]
23
+
24
+ def initialize: (
25
+ pool_name: Symbol | String,
26
+ ?size: Integer,
27
+ ?max_retries: Integer,
28
+ ?retry_delay: Numeric,
29
+ ?cb_threshold: Integer,
30
+ ?cb_timeout: Numeric,
31
+ ?validator: ^(untyped) -> bool,
32
+ ?retryable_errors: Array[Class],
33
+ ?factory: _SidekiqRatomicFactory
34
+ ) { () -> untyped } -> void
35
+
36
+ def call: (untyped, untyped, untyped) { () -> untyped } -> untyped
37
+ def close: () -> nil
38
+ def shutdown: () -> nil
39
+ def with: () { (untyped) -> untyped } -> untyped
40
+ def state: () -> Symbol
41
+ private
42
+ def validate_options!: (untyped, untyped, untyped, untyped, untyped) -> void
43
+ def check_circuit_state!: () -> void
44
+ def verify_health: (untyped) -> bool
45
+ def retryable_error?: (StandardError) -> bool
46
+ def work_error?: (StandardError) -> bool
47
+ def default_validator: (untyped) -> bool
48
+ def record_success: () -> void
49
+ def record_failure: () -> void
50
+ def monotonic_time: () -> Float
51
+ end
52
+ end
53
+ end
54
+
55
+ interface _SidekiqRatomicFactory
56
+ def call: () -> untyped
57
+ end
58
+
59
+ module Timeout
60
+ class Error < StandardError
61
+ end
62
+ end
@@ -0,0 +1,37 @@
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('SMOKE_JOB_COUNT', '20'))
9
+ timeout = Integer(ENV.fetch('SMOKE_TIMEOUT', '30'))
10
+ run_id = ENV.fetch('SMOKE_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', SmokeTest::COUNT_KEY, SmokeTest::RESULTS_KEY)
15
+ job_ids = job_count.times.map do |index|
16
+ job_id = "#{run_id}-#{index}"
17
+ Sidekiq::Client.push('class' => SmokeTest::RedisSmokeWorker, 'args' => [job_id], 'queue' => 'smoke')
18
+ job_id
19
+ end
20
+
21
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
22
+ loop do
23
+ processed = Integer(redis.call('GET', SmokeTest::COUNT_KEY) || 0)
24
+ results = Integer(redis.call('HLEN', SmokeTest::RESULTS_KEY) || 0)
25
+ break if processed >= job_count && results >= job_count
26
+
27
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
28
+ abort "Timed out waiting for #{job_count} jobs (processed=#{processed}, results=#{results})"
29
+ end
30
+
31
+ sleep 0.1
32
+ end
33
+
34
+ missing = job_ids.reject { |job_id| redis.call('HGET', SmokeTest::RESULTS_KEY, job_id) == 'processed' }
35
+ abort "Missing processed jobs: #{missing.join(', ')}" unless missing.empty?
36
+
37
+ puts "Smoke test passed: #{job_count} jobs processed by standalone Sidekiq through real Redis connections"
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sidekiq'
4
+ require 'redis-client'
5
+ require 'sidekiq_ratomic_pool'
6
+ require_relative 'worker'
7
+
8
+ RedisFactory = Data.define(:url) do
9
+ def call
10
+ RedisClient.config(url:).new_client
11
+ end
12
+ end
13
+
14
+ redis_port = ENV.fetch('REDIS_PORT', '6379')
15
+ redis_url = ENV.fetch('REDIS_URL', "redis://127.0.0.1:#{redis_port}/0").freeze
16
+
17
+ Sidekiq.configure_server do |config|
18
+ config.redis = { url: redis_url }
19
+ config.server_middleware do |chain|
20
+ chain.add(
21
+ Sidekiq::Ratomic::Pool,
22
+ pool_name: :redis_pool,
23
+ size: Integer(ENV.fetch('RATOMIC_POOL_SIZE', '4')),
24
+ max_retries: 3,
25
+ retry_delay: 0.05,
26
+ cb_threshold: 5,
27
+ cb_timeout: 5,
28
+ validator: ->(redis) { redis.call('PING') == 'PONG' },
29
+ factory: RedisFactory.new(redis_url).freeze
30
+ )
31
+ end
32
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmokeTest
4
+ COUNT_KEY = 'sidekiq-ratomic-pool:smoke:processed'
5
+ RESULTS_KEY = 'sidekiq-ratomic-pool:smoke:results'
6
+
7
+ # Sidekiq job that performs real Redis work through the injected pool.
8
+ class RedisSmokeWorker
9
+ include Sidekiq::Job
10
+
11
+ sidekiq_options queue: 'smoke', retry: 2
12
+
13
+ attr_accessor :redis_pool
14
+
15
+ def perform(job_id)
16
+ redis_pool.with do |redis|
17
+ redis.call('INCR', SmokeTest::COUNT_KEY)
18
+ redis.call('HSET', SmokeTest::RESULTS_KEY, job_id, 'processed')
19
+ work_seconds = Float(ENV.fetch('SMOKE_WORK_SECONDS', '0.05'))
20
+ sleep work_seconds if work_seconds.positive?
21
+ end
22
+ end
23
+ end
24
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sidekiq-ratomic-pool
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ken C. Demanawa
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ratomic
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.4.3
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.4.3
26
+ description: |
27
+ Thread and Ractor safe connection pooling utilizing Ratomic's LocalPool for Sidekiq middleware
28
+ with health validation, exponential retries, and circuit breakers.
29
+ email:
30
+ - kenneth.c.demanawa@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE.txt
37
+ - README.md
38
+ - lib/sidekiq/ratomic/pool.rb
39
+ - lib/sidekiq/ratomic/pool/errors.rb
40
+ - lib/sidekiq/ratomic/pool/version.rb
41
+ - lib/sidekiq_ratomic_pool.rb
42
+ - sig/sidekiq/ratomic/pool.rbs
43
+ - smoke_test/client.rb
44
+ - smoke_test/server.rb
45
+ - smoke_test/worker.rb
46
+ homepage: https://kanutocd.github.io/sidekiq-ratomic-pool
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://kanutocd.github.io/sidekiq-ratomic-pool
51
+ source_code_uri: https://github.com/kanutocd/sidekiq-ratomic-pool
52
+ changelog_uri: https://github.com/kanutocd/sidekiq-ratomic-pool/blob/main/CHANGELOG.md
53
+ documentation_uri: https://kanutocd.github.io/sidekiq-ratomic-pool
54
+ rubygems_mfa_required: 'true'
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '4.0'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 4.0.18
70
+ specification_version: 4
71
+ summary: Ractor-safe connection pooling for Sidekiq utilizing Ratomic's LocalPool.
72
+ test_files: []