sidekiq-fiber 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: 36e401e9716c74f87eb0423885cb6399200c06ee4ba4d55bf915be1bc2ed887b
4
+ data.tar.gz: 57bdd8cae18223a7264461ff58e694821ab6c576767e24839847c8e2d4ab2e3c
5
+ SHA512:
6
+ metadata.gz: ca3c3d30d72a0e682dac97f1dccf9e29b0501df8301f035c5149f6df5ec69f80059474514b03d64601ec6ee4579dc02a52d660fe7103fe22186a975476a3ab17
7
+ data.tar.gz: 9e2fa6927df3a6caa33f3254722b3f8fdd57020c753c92303271c916e95ca5e40c88585dcc53e55a957df7b39a7a5b392766c230fb5219420e5f43a98785cdfa
data/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # sidekiq-fiber
2
+
3
+ Fiber-based concurrency for IO-bound Sidekiq jobs.
4
+
5
+ If your jobs mostly wait on network calls — LLM APIs, HTTP requests, S3 — threads are the wrong tool. A thread blocks completely during IO. With 20 threads and 1500 queued jobs, you're processing 20 at a time and the rest sit idle.
6
+
7
+ This gem helps you solve this by using Ruby Fibers which take up a few kb per fiber and can help process 100s of fibers per thread.
8
+
9
+ ## How it works
10
+
11
+ Normal Sidekiq:
12
+ ```
13
+ Thread → fetch job → perform (blocks on IO) → fetch next
14
+ ```
15
+
16
+ With sidekiq-fiber:
17
+ ```
18
+ Thread → fetch job 1 → schedule as fiber → hits IO, suspends
19
+ → fetch job 2 → schedule as fiber → hits IO, suspends
20
+ → fetch job 3 → schedule as fiber → running
21
+ → job 1 resumes (IO done) → completes → fetch job 4
22
+ ```
23
+
24
+ One thread. Many concurrent fibers. No blocking.
25
+
26
+ ## Requirements
27
+
28
+ - Ruby 3.2+
29
+ - Sidekiq 7.0+
30
+ - Jobs must use fiber-aware IO clients (see table below)
31
+
32
+ ## Installation
33
+
34
+ ```ruby
35
+ gem "sidekiq-fiber"
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ **1. Configure a dedicated fiber capsule**
41
+
42
+ ```ruby
43
+ # config/initializers/sidekiq.rb
44
+ require "sidekiq-fiber"
45
+
46
+ Sidekiq.configure_server do |config|
47
+ config[:fiber_concurrency] = 50 # max concurrent fibers per thread
48
+
49
+ config.capsule("fiber") do |cap|
50
+ cap.concurrency = 20 # threads
51
+ cap.queues = ["llm_jobs", "api_calls"]
52
+ cap.processor_class = Sidekiq::Fiber::Processor
53
+ end
54
+ end
55
+ ```
56
+
57
+ **2. Opt in per job**
58
+
59
+ ```ruby
60
+ class MyLlmJob
61
+ include Sidekiq::Worker
62
+ include Sidekiq::Fiber::Worker # opt in — you are responsible for fiber-safe IO
63
+
64
+ def perform(id)
65
+ # Net::HTTP is fiber-aware in Ruby 3.2+ — yields during socket wait
66
+ response = Net::HTTP.get(URI("https://api.openai.com/..."))
67
+ process(response)
68
+ end
69
+ end
70
+ ```
71
+
72
+ **3. Add the Web UI tab (optional)**
73
+
74
+ ```ruby
75
+ # config/routes.rb
76
+ require "sidekiq/fiber/web"
77
+
78
+ Sidekiq::Web.configure do |config|
79
+ config.register Sidekiq::Fiber::Web,
80
+ name: "sidekiq-fiber",
81
+ tab: "Fibers",
82
+ index: "fiber-stats",
83
+ root_dir: Gem.loaded_specs["sidekiq-fiber"].gem_dir
84
+ end
85
+ ```
86
+
87
+ ## Fiber-safe IO
88
+
89
+ You're responsible for using fiber-aware clients. Blocking IO freezes the whole thread.
90
+
91
+ | Client | Fiber-aware in Ruby 3.2? |
92
+ |---|---|
93
+ | `Net::HTTP` | ✅ Yes — via fiber scheduler |
94
+ | `Faraday` (net-http adapter) | ✅ Yes |
95
+ | `HTTParty` | ✅ Yes (uses Net::HTTP) |
96
+ | `ActiveRecord` queries | ✅ Yes — with our connection pool patch |
97
+ | `Typhoeus` | ❌ No — uses libcurl |
98
+
99
+ ### ActiveRecord connection pool
100
+
101
+ sidekiq-fiber patches `ActiveRecord::ConnectionPool` to use `Fiber.current` as the connection cache key instead of `Thread.current`. Each fiber gets its own connection slot.
102
+
103
+ Keep this in mind when sizing your DB pool:
104
+
105
+ ```
106
+ max DB connections = threads × fiber_concurrency
107
+ ```
108
+
109
+ If your DB pool is 100 and you have 20 threads, set `fiber_concurrency` to 5 or lower. The Web UI will warn you when you're over this limit
110
+
111
+ ## Capacity planning
112
+
113
+ ```
114
+ max concurrent fibers = capsule.concurrency × fiber_concurrency
115
+ max DB connections = capsule.concurrency × fiber_concurrency
116
+ peak memory (HTTP) ≈ max concurrent fibers × ~50KB per connection
117
+ ```
118
+
119
+ ## Benchmark
120
+
121
+ Measured on Ruby 3.2.2, MacBook Air M2.
122
+ **Thread model:** 20 threads, one job at a time (max 20 concurrent)
123
+ **Fiber model:** 20 threads × 50 fibers (max 1000 concurrent)
124
+
125
+ ### Part 1 — sleep() simulation (fixed latency)
126
+
127
+ `sleep()` is fiber-scheduler aware in Ruby 3.2. In a thread it blocks completely. In a fiber it yields, so other fibers can run. Results are clean and predictable here.
128
+
129
+ | Scenario | Jobs | IO wait | Threads | Fibers | Speedup |
130
+ |---|---|---|---|---|---|
131
+ | Light | 50 | 0.1s | 0.32s | 0.10s | **3.0x** |
132
+ | Medium | 200 | 0.5s | 5.04s | 0.51s | **9.9x** |
133
+ | Heavy | 500 | 1.0s | 25.10s | 1.02s | **24.7x** |
134
+
135
+ ### Part 2 — Real HTTP calls (variable latency)
136
+
137
+ Jobs make actual `Net::HTTP` requests to a local WEBrick server that responds after a random delay. Real socket IO. `Net::HTTP` in Ruby 3.2 hooks into the fiber scheduler — the socket read suspends the fiber and resumes it when data arrives.
138
+
139
+ | Scenario | Jobs | Latency range | Threads | Fibers | Speedup |
140
+ |---|---|---|---|---|---|
141
+ | Fast API | 50 | 0.05s–0.3s | 0.63s | 0.31s | **2.1x** |
142
+ | Medium API | 100 | 0.5s–2.0s | 7.64s | 2.06s | **3.7x** |
143
+ | LLM-like | 50 | 2.0s–8.0s | 16.40s | 7.84s | **2.1x** |
144
+ | **Real burst** | **1500** | **0.5s–2.0s** | **95.46s** | **17.44s** | **5.5x** |
145
+
146
+ ### Inference
147
+
148
+ **Speedup scales with job_count / thread_count.** The more jobs you have relative to threads, the more fibers help. The Heavy scenario hits 24.7x because 500 jobs / 20 threads = 25 serial batches — fibers collapse that to 1. (Best usecase)
149
+
150
+ **The LLM-like scenario (50 jobs, 2-8s) is only 2.1x** because with 50 jobs and 1000 fiber slots, all 50 start immediately. Both approaches then wait for the slowest request (~8s). Fibers don't make requests faster — they stop threads from sitting idle during IO.
151
+
152
+ **The Real burst scenario (1500 jobs, 0.5-2.0s) is the honest one** — closest to high traffic production LLM workloads. Threads: 95s. Fibers: 17s. Threads process 20 at a time across 75 serial batches. Fibers process up to 1000 across 2 batches.
153
+
154
+ **Memory:** the Real burst run peaked at +52.9MB for fibers vs +3.3MB for threads. 1000 concurrent HTTP connections means 1000 open sockets with buffers and response objects in memory. Set `fiber_concurrency` based on your memory budget, not just throughput.
155
+
156
+ To reproduce:
157
+
158
+ ```bash
159
+ bundle exec ruby bench/throughput.rb
160
+ ```
161
+
162
+ ## Web UI
163
+
164
+ The optional "Fibers" tab shows:
165
+
166
+ - **Global health** — active fibers, semaphore utilization, DB connection warning
167
+ - **Per-thread breakdown** — semaphore fill, throughput, blocking IO detection
168
+ - **In-flight fibers** — long-running fiber alerts (> 30s)
169
+
170
+ **Blocking IO detection:** if a thread has active fibers but zero completions for > 60 seconds, the UI flags it. This catches jobs accidentally using non-fiber-aware clients that freeze the whole thread.
171
+
172
+ ## Design decisions
173
+
174
+ **Why a dedicated capsule, not mixed with normal jobs?**
175
+ Fiber and non-fiber jobs on the same processor share thread-level interrupt handling and connection pool state in ways that are subtle to reason about. A dedicated capsule makes the boundary structural and hence avoid accidental issues.
176
+
177
+ **Why the `async` gem for the event loop?**
178
+ The fiber scheduler interface in Ruby 3.2 has edge cases around IO readiness, timeout handling, and signal interrupts. `async` is battle tested and I didn't want to reinvent the wheel for this.
179
+
180
+ **Why patch `connection_cache_key` instead of a custom pool?**
181
+ The patch is 3 lines and uses the pool's designed extension point. A custom pool would duplicate hundreds of lines of connection management logic and diverge from ActiveRecord's battle-tested implementation.
182
+
183
+ **Why opt-in per job instead of per queue?**
184
+ Writing fiber-safe code requires deliberate choices about which IO clients you use. Making it explicit at the job class level enforces that — a queue declaration hides it. If you use a blocking client inside a fiber job, the whole thread stalls silently. Explicit opt-in helps the developers make accidental mistakes.
185
+
186
+ ## License
187
+
188
+ MIT
@@ -0,0 +1,27 @@
1
+ module Sidekiq
2
+ module Fiber
3
+ # Patches ActiveRecord's connection pool to use Fiber.current as the
4
+ # cache key instead of Thread.current.
5
+ #
6
+ # Without this patch, all fibers on the same thread share one connection
7
+ # slot — causing either connection sharing (data corruption) or unbounded
8
+ # connection checkout (one per fiber).
9
+ #
10
+ # With this patch, each fiber gets its own connection slot. The developer
11
+ # must still bound fiber concurrency via fiber_concurrency config to avoid
12
+ # exhausting the database connection limit.
13
+ #
14
+ # Only applied when ActiveRecord is present.
15
+ module ConnectionPoolPatch
16
+ def connection_cache_key(_thread)
17
+ ::Fiber.current
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ if defined?(ActiveRecord::ConnectionAdapters::ConnectionPool)
24
+ ActiveRecord::ConnectionAdapters::ConnectionPool.prepend(
25
+ Sidekiq::Fiber::ConnectionPoolPatch
26
+ )
27
+ end
@@ -0,0 +1,46 @@
1
+ require "sidekiq/manager"
2
+ require "sidekiq/capsule"
3
+
4
+ module Sidekiq
5
+ module Fiber
6
+ # Extends Sidekiq::Capsule with a per-capsule processor_class attribute.
7
+ # We can't use capsule[:processor_class] because capsule delegates [] to
8
+ # the global config — setting it would affect all capsules.
9
+ module CapsulePatch
10
+ def processor_class=(klass)
11
+ @processor_class = klass
12
+ end
13
+
14
+ def processor_class
15
+ @processor_class
16
+ end
17
+ end
18
+
19
+ # Patches Sidekiq::Manager to respect the per-capsule processor_class.
20
+ module ManagerPatch
21
+ def initialize(capsule)
22
+ super
23
+ klass = capsule.respond_to?(:processor_class) && capsule.processor_class
24
+ @processor_class = klass || Sidekiq::Processor
25
+ @workers.clear
26
+ @count.times do
27
+ @workers << @processor_class.new(@config, &method(:processor_result))
28
+ end
29
+ end
30
+
31
+ def processor_result(processor, reason = nil)
32
+ @plock.synchronize do
33
+ @workers.delete(processor)
34
+ unless @done
35
+ p = @processor_class.new(@config, &method(:processor_result))
36
+ @workers << p
37
+ p.start
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ Sidekiq::Capsule.prepend(Sidekiq::Fiber::CapsulePatch)
46
+ Sidekiq::Manager.prepend(Sidekiq::Fiber::ManagerPatch)
@@ -0,0 +1,155 @@
1
+ require "async"
2
+ require "sidekiq/processor"
3
+ require_relative "stats"
4
+
5
+ module Sidekiq
6
+ module Fiber
7
+ # A Sidekiq::Processor replacement that runs fiber-aware jobs as fibers
8
+ # inside a per-thread Async event loop.
9
+ #
10
+ # One event loop runs per thread. Each fiber job is scheduled as a child
11
+ # task inside that loop. The thread fetches jobs continuously, scheduling
12
+ # each as a fiber without waiting for previous fibers to finish.
13
+ #
14
+ # A semaphore bounds concurrent fibers per thread. This directly controls
15
+ # how many DB connections this processor can consume:
16
+ # max_connections = threads * fiber_concurrency
17
+ #
18
+ # On normal shutdown (@done = true), the fetch loop exits but the event
19
+ # loop waits for all in-flight fibers to complete before returning.
20
+ #
21
+ # On hard shutdown (Sidekiq::Shutdown raised), in-flight fibers that have
22
+ # not completed are requeued — identical to Sidekiq's default behaviour.
23
+ class Processor < Sidekiq::Processor
24
+ def initialize(capsule, &block)
25
+ super
26
+ @fiber_concurrency = capsule.config[:fiber_concurrency] || 100
27
+ @active_fibers = 0
28
+ @active_fibers_lock = Mutex.new
29
+ stats_pool = capsule.config.new_redis_pool(@fiber_concurrency, "sidekiq-fiber-stats")
30
+ @stats = Stats.new(stats_pool)
31
+ end
32
+
33
+ private
34
+
35
+ # Replaces the default run loop with a fixed pool of fiber workers.
36
+ #
37
+ # We spawn exactly fiber_concurrency persistent fibers. Each fiber owns
38
+ # its own fetch-process loop: it fetches one job, processes it, then
39
+ # fetches the next. This means jobs stay in Redis until there is actual
40
+ # capacity — we never drain the queue into memory ahead of processing.
41
+ #
42
+ # The previous design (single fetch loop + semaphore) fetched unboundedly
43
+ # fast, pulling all queued jobs into pending async tasks before any fiber
44
+ # started working. With 5000 jobs enqueued that meant 5000 tasks created
45
+ # instantly, all invisible to the Sidekiq UI.
46
+ def run
47
+ Thread.current[:sidekiq_capsule] = @capsule
48
+
49
+ thread_id = Thread.current.object_id.to_s
50
+
51
+ @stats.register_thread(thread_id: thread_id, fiber_concurrency: @fiber_concurrency)
52
+
53
+ Async do |task|
54
+ workers = @fiber_concurrency.times.map do
55
+ task.async do
56
+ until @done
57
+ uow = fetch
58
+ unless uow
59
+ task.yield # nothing in queue — yield so other fibers can run
60
+ next
61
+ end
62
+
63
+ klass_name = begin
64
+ Sidekiq.load_json(uow.job)["class"]
65
+ rescue
66
+ nil
67
+ end
68
+
69
+ is_fiber_job = klass_name &&
70
+ Object.const_get(klass_name).include?(Sidekiq::Fiber::Worker)
71
+
72
+ if is_fiber_job
73
+ active = @active_fibers_lock.synchronize { @active_fibers += 1 }
74
+ @stats.update_thread_stats(
75
+ thread_id: thread_id,
76
+ semaphore_size: @fiber_concurrency,
77
+ semaphore_acquired: active
78
+ )
79
+ process_in_fiber(uow, thread_id: thread_id)
80
+ active = @active_fibers_lock.synchronize { @active_fibers -= 1 }
81
+ @stats.update_thread_stats(
82
+ thread_id: thread_id,
83
+ semaphore_size: @fiber_concurrency,
84
+ semaphore_acquired: active
85
+ )
86
+ else
87
+ process(uow)
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ workers.each(&:wait)
94
+ end
95
+
96
+ @stats.deregister_thread(thread_id: thread_id)
97
+ @callback.call(self)
98
+ rescue Sidekiq::Shutdown
99
+ @stats.deregister_thread(thread_id: Thread.current.object_id.to_s)
100
+ @callback.call(self)
101
+ rescue Exception => ex
102
+ @stats.deregister_thread(thread_id: Thread.current.object_id.to_s)
103
+ @callback.call(self, ex)
104
+ end
105
+
106
+ # Runs a single job unit of work inside the current fiber.
107
+ # Mirrors Sidekiq::Processor#process but with per-fiber ack tracking
108
+ # so that hard shutdown can requeue incomplete jobs correctly.
109
+ def process_in_fiber(uow, thread_id:)
110
+ jobstr = uow.job
111
+ queue = uow.queue_name
112
+ job_hash = nil
113
+
114
+ begin
115
+ job_hash = Sidekiq.load_json(jobstr)
116
+ rescue => ex
117
+ handle_exception(ex, { context: "Invalid JSON for job", jobstr: jobstr })
118
+ return uow.acknowledge
119
+ end
120
+
121
+ jid = job_hash["jid"]
122
+ job_class = job_hash["class"]
123
+ ack = false
124
+
125
+ @stats.fiber_started(jid: jid, job_class: job_class, thread_id: thread_id)
126
+
127
+ begin
128
+ dispatch(job_hash, queue, jobstr) do |instance|
129
+ @capsule.config.server_middleware.invoke(instance, job_hash, queue) do
130
+ execute_job(instance, job_hash["args"])
131
+ end
132
+ end
133
+ ack = true
134
+ rescue Sidekiq::Shutdown
135
+ # Fiber was stopped before job completed. Do not acknowledge.
136
+ # Job will be requeued by the capsule fetcher on shutdown.
137
+ rescue Sidekiq::JobRetry::Skip => s
138
+ ack = true
139
+ raise s
140
+ rescue Sidekiq::JobRetry::Handled => h
141
+ ack = true
142
+ e = h.cause || h
143
+ handle_exception(e, { context: "Job raised exception", job: job_hash })
144
+ raise e
145
+ rescue Exception => ex
146
+ handle_exception(ex, { context: "Internal exception!", job: job_hash, jobstr: jobstr })
147
+ raise ex
148
+ ensure
149
+ @stats.fiber_completed(jid: jid, thread_id: thread_id)
150
+ uow.acknowledge if ack
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,136 @@
1
+ module Sidekiq
2
+ module Fiber
3
+ # Writes fiber execution stats to Redis.
4
+ # Called from the processor at key lifecycle points:
5
+ # - fiber starts → record in-flight fiber
6
+ # - fiber completes → remove in-flight, increment completed counter
7
+ # - semaphore changes → update utilization
8
+ #
9
+ # All keys are namespaced under "sidekiq-fiber:" and expire automatically
10
+ # so stale data doesn't accumulate after a worker restarts.
11
+ class Stats
12
+ NAMESPACE = "sidekiq-fiber"
13
+ FIBER_TTL = 600 # 10 minutes — long-running fiber alert threshold
14
+ THREAD_TTL = 120 # 2 minutes — thread stats expire if worker dies
15
+ GLOBAL_TTL = 120
16
+
17
+ def initialize(redis_pool)
18
+ @redis = redis_pool
19
+ end
20
+
21
+ # Called when a fiber starts executing a job.
22
+ def fiber_started(jid:, job_class:, thread_id:)
23
+ safe_redis do |conn|
24
+ key = fiber_key(jid)
25
+ conn.hset(key,
26
+ "job_class", job_class,
27
+ "thread_id", thread_id,
28
+ "started_at", Time.now.to_f
29
+ )
30
+ conn.expire(key, FIBER_TTL)
31
+ end
32
+ end
33
+
34
+ def fiber_completed(jid:, thread_id:)
35
+ safe_redis do |conn|
36
+ conn.del(fiber_key(jid))
37
+ conn.hincrby(thread_key(thread_id), "completed_total", 1)
38
+ conn.hset(thread_key(thread_id), "last_completed_at", Time.now.to_f)
39
+ conn.expire(thread_key(thread_id), THREAD_TTL)
40
+ end
41
+ end
42
+
43
+ def update_thread_stats(thread_id:, semaphore_size:, semaphore_acquired:)
44
+ safe_redis do |conn|
45
+ conn.hset(thread_key(thread_id),
46
+ "semaphore_size", semaphore_size,
47
+ "semaphore_acquired", semaphore_acquired
48
+ )
49
+ conn.expire(thread_key(thread_id), THREAD_TTL)
50
+ end
51
+ end
52
+
53
+ def register_thread(thread_id:, fiber_concurrency:)
54
+ safe_redis do |conn|
55
+ conn.sadd(threads_index_key, thread_id)
56
+ conn.expire(threads_index_key, GLOBAL_TTL)
57
+ conn.hset(thread_key(thread_id),
58
+ "semaphore_size", fiber_concurrency,
59
+ "semaphore_acquired", 0,
60
+ "completed_total", 0,
61
+ "last_completed_at", ""
62
+ )
63
+ conn.expire(thread_key(thread_id), THREAD_TTL)
64
+ end
65
+ end
66
+
67
+ def deregister_thread(thread_id:)
68
+ safe_redis do |conn|
69
+ conn.srem(threads_index_key, thread_id)
70
+ conn.del(thread_key(thread_id))
71
+ end
72
+ end
73
+
74
+ # ── Readers (used by Web UI) ─────────────────────────────────────────────
75
+
76
+ def all_thread_stats
77
+ @redis.with do |conn|
78
+ thread_ids = conn.smembers(threads_index_key)
79
+ thread_ids.filter_map do |tid|
80
+ stats = conn.hgetall(thread_key(tid))
81
+ next if stats.empty?
82
+ stats.merge("thread_id" => tid)
83
+ end
84
+ end
85
+ end
86
+
87
+ def all_in_flight_fibers
88
+ @redis.with do |conn|
89
+ keys = conn.keys("#{NAMESPACE}:fiber:*")
90
+ keys.filter_map do |key|
91
+ data = conn.hgetall(key)
92
+ next if data.empty?
93
+ jid = key.split(":").last
94
+ data.merge(
95
+ "jid" => jid,
96
+ "running_for" => (Time.now.to_f - data["started_at"].to_f).round(1)
97
+ )
98
+ end
99
+ end
100
+ end
101
+
102
+ def global_summary
103
+ @redis.with do |conn|
104
+ thread_ids = conn.smembers(threads_index_key)
105
+ total_active = 0
106
+ total_max = 0
107
+
108
+ thread_ids.each do |tid|
109
+ stats = conn.hgetall(thread_key(tid))
110
+ total_active += stats["semaphore_acquired"].to_i
111
+ total_max += stats["semaphore_size"].to_i
112
+ end
113
+
114
+ {
115
+ thread_count: thread_ids.size,
116
+ total_active: total_active,
117
+ total_max: total_max
118
+ }
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def safe_redis(&block)
125
+ @redis.with(&block)
126
+ rescue ConnectionPool::TimeoutError, StandardError
127
+ # Stats writes are best-effort. A timeout or Redis blip should never
128
+ # propagate into the fiber and fail the job.
129
+ end
130
+
131
+ def fiber_key(jid) = "#{NAMESPACE}:fiber:#{jid}"
132
+ def thread_key(thread_id) = "#{NAMESPACE}:thread:#{thread_id}"
133
+ def threads_index_key = "#{NAMESPACE}:threads"
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,5 @@
1
+ module Sidekiq
2
+ module Fiber
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,59 @@
1
+ require "sidekiq/web"
2
+ require_relative "stats"
3
+
4
+ module Sidekiq
5
+ module Fiber
6
+ # Sidekiq Web UI extension for fiber stats.
7
+ #
8
+ # Registers a "Fibers" tab that shows:
9
+ # - Global summary (active fibers, semaphore utilization, DB connection warning)
10
+ # - Per-thread breakdown (semaphore fill, throughput, blocking IO detection)
11
+ # - In-flight fibers (long-running fiber alert)
12
+ #
13
+ # Usage in config/routes.rb or rack config:
14
+ #
15
+ # require "sidekiq/fiber/web"
16
+ # Sidekiq::Web.configure do |config|
17
+ # config.register Sidekiq::Fiber::Web,
18
+ # name: "sidekiq-fiber",
19
+ # tab: "Fibers",
20
+ # index: "fiber-stats",
21
+ # root_dir: File.expand_path("../../../..", __FILE__)
22
+ # end
23
+ module Web
24
+ VIEWS = File.expand_path("../../../web/views", __dir__)
25
+ ROOT = File.expand_path("../../../", __dir__)
26
+
27
+ def self.registered(app)
28
+ app.get "/fiber-stats" do
29
+ pool = Sidekiq.default_configuration.redis_pool
30
+ stats = Sidekiq::Fiber::Stats.new(pool)
31
+
32
+ @global = stats.global_summary
33
+ @thread_stats = stats.all_thread_stats.sort_by { |t| t["thread_id"] }
34
+ @in_flight = stats.all_in_flight_fibers.sort_by { |f| -f["running_for"].to_f }
35
+
36
+ # DB connection warning threshold
37
+ @db_pool_size = begin
38
+ ActiveRecord::Base.connection_pool.size
39
+ rescue
40
+ nil
41
+ end
42
+
43
+ render(:erb, File.read(File.join(VIEWS, "fiber_stats.html.erb")))
44
+ end
45
+
46
+ app.get "/fiber-stats/thread/:thread_id" do
47
+ pool = Sidekiq.default_configuration.redis_pool
48
+ stats = Sidekiq::Fiber::Stats.new(pool)
49
+
50
+ thread_id = route_params(:thread_id)
51
+ @thread_stats = stats.all_thread_stats.find { |t| t["thread_id"] == thread_id }
52
+ @in_flight = stats.all_in_flight_fibers.select { |f| f["thread_id"] == thread_id }
53
+
54
+ render(:erb, File.read(File.join(VIEWS, "fiber_thread.html.erb")))
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,24 @@
1
+ module Sidekiq
2
+ module Fiber
3
+ # Marker module. Including this in a Sidekiq job class signals to
4
+ # SidekiqFiber::Processor that this job should run as a fiber.
5
+ #
6
+ # The developer is responsible for ensuring all IO inside the job
7
+ # uses fiber-aware clients. Blocking IO (e.g. a non-patched HTTP
8
+ # client) will block the entire thread, defeating the purpose.
9
+ #
10
+ # Example:
11
+ #
12
+ # class MyJob
13
+ # include Sidekiq::Worker
14
+ # include Sidekiq::Fiber::Worker
15
+ #
16
+ # def perform(id)
17
+ # # only fiber-aware IO here
18
+ # Net::HTTP.get(URI("https://api.example.com/#{id}"))
19
+ # end
20
+ # end
21
+ module Worker
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,8 @@
1
+ require "sidekiq"
2
+ require "async"
3
+
4
+ require_relative "sidekiq/fiber/version"
5
+ require_relative "sidekiq/fiber/worker"
6
+ require_relative "sidekiq/fiber/processor"
7
+ require_relative "sidekiq/fiber/connection_pool_patch"
8
+ require_relative "sidekiq/fiber/manager_patch"
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sidekiq-fiber
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yash Dave
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sidekiq
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: async
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ description: |
56
+ sidekiq-fiber lets you run IO-bound Sidekiq jobs as fibers instead of threads.
57
+ A single thread can process thousands of concurrent jobs that spend most of
58
+ their time waiting on external IO (HTTP, LLM APIs, S3) — without the memory
59
+ and OS overhead of one thread per job.
60
+ email:
61
+ - yash@skima.ai
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - README.md
67
+ - lib/sidekiq-fiber.rb
68
+ - lib/sidekiq/fiber/connection_pool_patch.rb
69
+ - lib/sidekiq/fiber/manager_patch.rb
70
+ - lib/sidekiq/fiber/processor.rb
71
+ - lib/sidekiq/fiber/stats.rb
72
+ - lib/sidekiq/fiber/version.rb
73
+ - lib/sidekiq/fiber/web.rb
74
+ - lib/sidekiq/fiber/worker.rb
75
+ homepage: https://github.com/yashdave00/sidekiq-fiber
76
+ licenses:
77
+ - MIT
78
+ metadata: {}
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.0.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.4.10
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: Fiber-based concurrency for Sidekiq IO-bound jobs
98
+ test_files: []