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 +7 -0
- data/README.md +188 -0
- data/lib/sidekiq/fiber/connection_pool_patch.rb +27 -0
- data/lib/sidekiq/fiber/manager_patch.rb +46 -0
- data/lib/sidekiq/fiber/processor.rb +155 -0
- data/lib/sidekiq/fiber/stats.rb +136 -0
- data/lib/sidekiq/fiber/version.rb +5 -0
- data/lib/sidekiq/fiber/web.rb +59 -0
- data/lib/sidekiq/fiber/worker.rb +24 -0
- data/lib/sidekiq-fiber.rb +8 -0
- metadata +98 -0
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,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: []
|