sidekiq-routing 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0119411113d408ffff9aa353faf8a1d9b4dfd7572edd26efb39dc97b048d204e'
4
- data.tar.gz: cec0724470361bdbf22d98b9be14387acbd46c1f895ad3f0f4f27d3f77a3f477
3
+ metadata.gz: 4026ae7773cd59cf7d6f4c47f965fbd6d4f27c2f337d1043a67c6c6ef9a4982c
4
+ data.tar.gz: 57da9b38e4ae73d1ce7664bf54c851941527641cb8d596fa312283172f297983
5
5
  SHA512:
6
- metadata.gz: 6f299c288a571de2e21cfb6a8ece1be70df3b30f16b6251c1eb81eacb29ab788eea272d51187f6b919fbdbef07f8f176d6291637aefc32190699c90d20b3e702
7
- data.tar.gz: fa64c7f1d15224a0011e9f5a7f16747ca1699781cdb50a6d84b69281814885481fc1ebf555bb9ea59a02727f2fa1536627900ebcc94631fb30af413b1d4f0537
6
+ metadata.gz: fa372018993a3b5ee70f4b19b4ddb26a344a1f1b244063a51ab122632b4808721d3085ebf7de584ec998bb475b32461758172549d92d49c66104939164e941b9
7
+ data.tar.gz: 9a7864642c6695d4313dc69170cc09925e088ee9e72d13239c163010b4c066fb16822bdf5e07ab01a8be8341ffdfd84dce905f2ef714773f586938a280fbd02b
data/README.md CHANGED
@@ -4,11 +4,11 @@ Runtime, per-job-class **queue routing for Sidekiq** — park, blackhole, or
4
4
  auto-reroute a job class **without a deploy**.
5
5
 
6
6
  Background jobs often share a handful of latency-tiered queues
7
- (`within_5_seconds`, `within_1_minute`, …). The queue name is a contract: every
8
- job on a tier should start within that window. That sharing is efficient until
9
- one class misbehaves — a flood, a runaway argument, a broken downstream — and
10
- then the only built-in lever, pausing the whole queue, punishes every other
11
- class on the tier. `sidekiq-routing` gives you a finer lever, applied at
7
+ (`within_5_seconds`, `within_1_minute`, …). The queue name is a contract:
8
+ every job on a tier should start within that window. That sharing is efficient
9
+ until one class misbehaves — a flood, a runaway argument, a broken downstream
10
+ — and then the only built-in lever, pausing the whole queue, punishes every
11
+ other class on the tier. `sidekiq-routing` gives you a finer lever, applied at
12
12
  runtime to a single job class:
13
13
 
14
14
  | Kind | Purpose | Driver | Target |
@@ -33,7 +33,7 @@ bundle install
33
33
  ```
34
34
 
35
35
  Requires Ruby >= 3.1 and Sidekiq >= 7.0 (tested on Sidekiq 7.3.x and 8.x). The
36
- gem depends only on `sidekiq` — no Rails or ActiveSupport required.
36
+ gem depends only on `sidekiq`.
37
37
 
38
38
  ## Quick start
39
39
 
@@ -52,9 +52,7 @@ Sidekiq::Routing.install! # registers the client + server middleware
52
52
  ```
53
53
 
54
54
  `install!` prepends the client middleware (so enqueues are diverted) and adds
55
- the server middleware (so in-flight jobs are diverted). If
56
- [sidekiq-unique-jobs](https://github.com/mhenrixon/sidekiq-unique-jobs) is
57
- present, the routing server middleware is inserted *after* it automatically.
55
+ the server middleware (so in-flight jobs are diverted).
58
56
 
59
57
  Then, from a console during an incident:
60
58
 
@@ -76,27 +74,42 @@ runtime, without pausing the rest of its queue or shipping a deploy.
76
74
  added to the Dead set). Only for classes you can afford to lose.
77
75
 
78
76
  ```ruby
79
- Sidekiq::Routing.park("HardJob")
77
+ Sidekiq::Routing.park("RunawayImportJob")
80
78
  Sidekiq::Routing.blackhole("FireAndForgetJob")
81
- Sidekiq::Routing.unpark("HardJob")
79
+ Sidekiq::Routing.unpark("RunawayImportJob")
82
80
 
83
- Sidekiq::Routing.routed?("HardJob") # => true/false
84
- Sidekiq::Routing.parked?("HardJob") # => true only when in park mode
85
- Sidekiq::Routing.mode("HardJob") # => "park" | "blackhole" | nil
86
- Sidekiq::Routing.routes # => { "HardJob" => { "mode" => "park", ... } }
81
+ Sidekiq::Routing.routed?("RunawayImportJob") # => true/false
82
+ Sidekiq::Routing.parked?("RunawayImportJob") # => true only when in park mode
83
+ Sidekiq::Routing.mode("RunawayImportJob") # => "park" | "blackhole" | nil
84
+ Sidekiq::Routing.routes # => { "RunawayImportJob" => { "mode" => "park", ... } }
87
85
  ```
88
86
 
89
87
  A route accepts a Class or a String. ActiveJob jobs are matched by their real
90
88
  ("wrapped") class, not the adapter's job wrapper.
91
89
 
90
+ ### Identifying the class flooding a live queue
91
+
92
+ During a latency alert, inspect the breached queue with a capped, read-only
93
+ scan:
94
+
95
+ ```ruby
96
+ report = Sidekiq::Routing.queue_composition("within_1_minute")
97
+ puts report
98
+ # RunawayImportJob count=12345 oldest=380s ago
99
+ # OtherJob count=12 oldest=45s ago
100
+ # scanned 12357 of 12357 (cap 250000)
101
+
102
+ report.offender["class"] # => "RunawayImportJob"
103
+ ```
104
+
92
105
  ### Clearing an existing backlog into the parking queue
93
106
 
94
107
  `park` only diverts jobs from the moment it's set. To move a class's jobs that
95
108
  are *already enqueued* on a live queue into the parking queue, sweep them:
96
109
 
97
110
  ```ruby
98
- Sidekiq::Routing.sweep("HardJob", queue: "within_1_minute")
99
- Sidekiq::Routing.sweep("HardJob", queue: "within_1_minute", limit: 10_000)
111
+ Sidekiq::Routing.sweep("RunawayImportJob", queue: "within_1_minute")
112
+ Sidekiq::Routing.sweep("RunawayImportJob", queue: "within_1_minute", limit: 10_000)
100
113
  ```
101
114
 
102
115
  A queue must be resolvable — pass `queue:` explicitly (the sweep deliberately
@@ -108,11 +121,11 @@ never scans every queue, which would hammer Redis during an incident).
108
121
  # Move parked jobs back to their original queue (stamps them so an active
109
122
  # route won't immediately bounce them back).
110
123
  Sidekiq::Routing.process_parked
111
- Sidekiq::Routing.process_parked(klass: "HardJob", limit: 1_000)
124
+ Sidekiq::Routing.process_parked(klass: "RunawayImportJob", limit: 1_000)
112
125
 
113
126
  # Introspection
114
127
  Sidekiq::Routing.parked_size # O(1) count of the parking queue
115
- Sidekiq::Routing.parked_breakdown # { "HardJob" => { "count" => 12, "by_original_queue" => {...} } }
128
+ Sidekiq::Routing.parked_breakdown # { "RunawayImportJob" => { "count" => 12, "by_original_queue" => {...} } }
116
129
  ```
117
130
 
118
131
  A processed parked job has its payload rewritten to target its original queue,
@@ -130,8 +143,8 @@ export SIDEKIQ_ROUTING_AUTO_REROUTE_ENABLED=true
130
143
  ```
131
144
 
132
145
  Auto rerouting only ever moves jobs to the *next* live SLA tier
133
- (`within_5_seconds` → `within_1_minute` → `within_5_minutes` → `within_1_hour`).
134
- It never parks or blackholes.
146
+ (`within_5_seconds` → `within_1_minute` → `within_5_minutes` →
147
+ `within_1_hour`). It never parks or blackholes.
135
148
 
136
149
  Wire it up in your initializer:
137
150
 
@@ -161,18 +174,18 @@ routing_auto_reroute:
161
174
  class: "Sidekiq::Routing::Auto::RerouteJob"
162
175
  ```
163
176
 
164
- `RerouteJob` checks each SLA queue's estimated workload against its capacity and,
165
- for any tier over `capacity_threshold_percent`, moves the noisiest classes to
166
- the next tier. `RerouteJob` itself is excluded from rerouting by default.
177
+ `RerouteJob` checks each SLA queue's estimated workload against its capacity
178
+ and, for any tier over `capacity_threshold_percent`, moves the noisiest classes
179
+ to the next tier. `RerouteJob` itself is excluded from rerouting by default.
167
180
 
168
181
  ## Web tab
169
182
 
170
183
  A read-only "Routing" tab for Sidekiq Web shows active routes and parking-queue
171
- depth/breakdown. Every mutating action stays on the console API — the tab never
172
- exposes park/blackhole/unpark/sweep, so destructive operations stay deliberate.
184
+ depth/breakdown. Every mutating action stays on the console API — the tab
185
+ never exposes park/blackhole/unpark/sweep, so destructive operations stay
186
+ deliberate.
173
187
 
174
- Require it only where you mount Sidekiq Web (so worker processes never load the
175
- web framework):
188
+ Require it only where you mount Sidekiq Web:
176
189
 
177
190
  ```ruby
178
191
  require "sidekiq/web"
@@ -180,6 +193,8 @@ require "sidekiq/routing/web" # registers the "Routing" tab
180
193
  mount Sidekiq::Web => "/sidekiq"
181
194
  ```
182
195
 
196
+ <img width="2964" height="1550" alt="CleanShot 2026-06-26 at 13 59 49@2x" src="https://github.com/user-attachments/assets/8da1149d-c76d-47d6-8be6-7aef921d54ea" />
197
+
183
198
  ## Configuration reference
184
199
 
185
200
  `Sidekiq::Routing.setup { |config| ... }`:
@@ -217,17 +232,6 @@ snapshot, so console changes take effect immediately for the writer and within
217
232
  one TTL everywhere else. Parking rewrites the `queue` field *inside the job
218
233
  payload*, which is what makes recovery and retry-to-original-queue correct.
219
234
 
220
- ## Development
221
-
222
- ```sh
223
- bin/setup # or: bundle install
224
- bundle exec rake test
225
- ```
226
-
227
- The test suite talks to a real Redis. It uses logical DB 15 of
228
- `redis://localhost:6379` by default; override with `REDIS_URL` (point it at a
229
- disposable Redis). No Rails or database is involved.
230
-
231
235
  ## License
232
236
 
233
237
  Released under the [MIT License](LICENSE).
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Routing
5
+ # Read-only, capped scan of a live queue grouped by displayed job class.
6
+ # Used during incidents to answer: "which class is flooding this SLA tier?"
7
+ class QueueComposition
8
+ DEFAULT_SCAN_LIMIT = 250_000
9
+
10
+ Report = Struct.new(:queue, :rows, :scanned, :size, :scan_limit, keyword_init: true) do
11
+ def offender
12
+ rows.first
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ "queue" => queue,
18
+ "rows" => rows.map(&:dup),
19
+ "scanned" => scanned,
20
+ "size" => size,
21
+ "scan_limit" => scan_limit
22
+ }
23
+ end
24
+
25
+ def to_s
26
+ lines = rows.map do |row|
27
+ age = row["oldest_age_seconds"] ? "#{row["oldest_age_seconds"].round}s ago" : "n/a"
28
+ format("%-55s count=%-9d oldest=%s", row["class"], row["count"], age)
29
+ end
30
+ lines << "scanned #{scanned} of #{size} (cap #{scan_limit})"
31
+ lines.join("\n")
32
+ end
33
+
34
+ alias inspect to_s
35
+ end
36
+
37
+ def initialize(queue_name, scan_limit: DEFAULT_SCAN_LIMIT, now: Time.now)
38
+ @queue_name = normalize_queue_name(queue_name)
39
+ @scan_limit = normalize_scan_limit(scan_limit)
40
+ @now = now
41
+ end
42
+
43
+ def call
44
+ by_class = Hash.new { |hash, key| hash[key] = { count: 0, oldest_at: nil } }
45
+ queue = Sidekiq::Queue.new(@queue_name)
46
+ scanned = 0
47
+
48
+ queue.each do |job|
49
+ break if scanned >= @scan_limit
50
+
51
+ scanned += 1
52
+ row = by_class[job.display_class]
53
+ row[:count] += 1
54
+
55
+ enqueued_at = job.enqueued_at
56
+ row[:oldest_at] = enqueued_at if older?(enqueued_at, row[:oldest_at])
57
+ end
58
+
59
+ Report.new(
60
+ queue: @queue_name,
61
+ rows: build_rows(by_class),
62
+ scanned: scanned,
63
+ size: queue.size,
64
+ scan_limit: @scan_limit
65
+ )
66
+ end
67
+
68
+ private
69
+
70
+ def build_rows(by_class)
71
+ by_class.sort_by { |klass, stats| [-stats[:count], klass] }.map do |klass, stats|
72
+ oldest_at = stats[:oldest_at]
73
+ {
74
+ "class" => klass,
75
+ "count" => stats[:count],
76
+ "oldest_at" => oldest_at,
77
+ "oldest_age_seconds" => oldest_at && (@now - oldest_at)
78
+ }
79
+ end
80
+ end
81
+
82
+ def normalize_queue_name(queue_name)
83
+ name = queue_name.to_s
84
+ raise ArgumentError, "queue_name must be present" if name.empty?
85
+
86
+ name
87
+ end
88
+
89
+ def normalize_scan_limit(scan_limit)
90
+ limit = begin
91
+ Integer(scan_limit)
92
+ rescue ArgumentError, TypeError
93
+ raise ArgumentError, "scan_limit must be a non-negative integer"
94
+ end
95
+ raise ArgumentError, "scan_limit must be non-negative" if limit.negative?
96
+
97
+ limit
98
+ end
99
+
100
+ def older?(candidate, current)
101
+ candidate && (current.nil? || candidate < current)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -27,14 +27,38 @@ module Sidekiq
27
27
  # -> {"mode"=>...} or nil
28
28
  def fetch(name)
29
29
  raw = Sidekiq.redis { |conn| conn.hget(HASH_KEY, name) }
30
- raw && JSON.parse(raw)
30
+ raw && parse_entry(name, raw)
31
31
  end
32
32
 
33
33
  # -> { "ClassName" => {"mode"=>...}, ... }
34
34
  def all
35
35
  Sidekiq.redis { |conn| conn.hgetall(HASH_KEY) }
36
- .transform_values { |raw| JSON.parse(raw) }
36
+ .each_with_object({}) do |(name, raw), routes|
37
+ entry = parse_entry(name, raw)
38
+ routes[name] = entry if entry
39
+ end
37
40
  end
41
+
42
+ private
43
+
44
+ # A route entry must be a JSON object; anything else is skipped with a
45
+ # warning, never raised on. The reads feed the client-middleware hot
46
+ # path inside every perform_async, and the raw value can be foreign
47
+ # data — e.g. a desynced pooled connection handing HGETALL the reply
48
+ # of a RESP HELLO handshake ({"server"=>"redis", "proto"=>"3", ...}).
49
+ def parse_entry(name, raw)
50
+ entry = JSON.parse(raw)
51
+ entry.is_a?(Hash) ? entry : warn_invalid(name, raw)
52
+ rescue JSON::ParserError
53
+ warn_invalid(name, raw)
54
+ end
55
+
56
+ # -> nil
57
+ def warn_invalid(name, raw)
58
+ Routing.logger.warn(
59
+ "[Routing] ignoring invalid route entry #{name.inspect} => #{raw.inspect.byteslice(0, 200)}")
60
+ nil
61
+ end
38
62
  end
39
63
  end
40
64
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sidekiq
4
4
  module Routing
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.2"
6
6
  end
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "sidekiq"
4
+ require "sidekiq/api"
4
5
  require "json"
5
6
 
6
7
  # Runtime, per-job-class parking/blackhole mechanism for Sidekiq incident response.
@@ -25,6 +26,8 @@ module Sidekiq
25
26
  MODE_BLACKHOLE = "blackhole"
26
27
  MODES = [MODE_PARK, MODE_BLACKHOLE].freeze
27
28
 
29
+ EMPTY_ROUTES = {}.freeze
30
+
28
31
  @snapshot = nil
29
32
  @snapshot_at = nil
30
33
  @snapshot_mutex = Mutex.new
@@ -121,7 +124,11 @@ module Sidekiq
121
124
  end
122
125
  end
123
126
 
124
- # ---- parking queue introspection ----
127
+ # ---- queue introspection ----
128
+
129
+ def queue_composition(queue_name, scan_limit: QueueComposition::DEFAULT_SCAN_LIMIT)
130
+ QueueComposition.new(queue_name, scan_limit:).call
131
+ end
125
132
 
126
133
  def parked_size
127
134
  Sidekiq::Queue.new(parked_queue).size
@@ -196,13 +203,25 @@ module Sidekiq
196
203
  current = @snapshot
197
204
  fresh = current && ttl.positive? && (now - @snapshot_at) < ttl
198
205
  unless fresh
199
- @snapshot = Store.all.freeze
206
+ @snapshot = refresh_snapshot(current)
200
207
  @snapshot_at = now
201
208
  current = @snapshot
202
209
  end
203
210
  end
204
211
  current
205
212
  end
213
+
214
+ # Fail open: the refresh runs inside every perform_async via the client
215
+ # middleware, so a Redis hiccup here must never fail the push. Keep
216
+ # serving the previous snapshot (or route nothing) until the next TTL
217
+ # window instead of raising.
218
+ def refresh_snapshot(current)
219
+ Store.all.freeze
220
+ rescue StandardError => error
221
+ fallback = current ? "keeping stale snapshot" : "routing nothing this TTL window"
222
+ logger.warn("[Routing] snapshot refresh failed, #{fallback} (#{error.class}: #{error.message})")
223
+ current || EMPTY_ROUTES
224
+ end
206
225
  end
207
226
  end
208
227
  end
@@ -13,6 +13,7 @@ require "sidekiq/routing"
13
13
  require "sidekiq/routing/configuration"
14
14
  require "sidekiq/routing/store"
15
15
  require "sidekiq/routing/mover"
16
+ require "sidekiq/routing/queue_composition"
16
17
  require "sidekiq/routing/sweeper"
17
18
  require "sidekiq/routing/parked_processor"
18
19
  require "sidekiq/routing/middleware/client"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sidekiq-routing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
- - Vishnu M
7
+ - Neeraj Singh
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-26 00:00:00.000000000 Z
11
+ date: 2026-07-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sidekiq
@@ -33,7 +33,7 @@ description: |
33
33
  from a process-local snapshot, so the per-job hot path stays an in-memory
34
34
  lookup rather than a Redis round-trip.
35
35
  email:
36
- - vishnu.m@bigbinary.com
36
+ - neeraj@neeto.com
37
37
  executables: []
38
38
  extensions: []
39
39
  extra_rdoc_files: []
@@ -53,6 +53,7 @@ files:
53
53
  - lib/sidekiq/routing/middleware/server.rb
54
54
  - lib/sidekiq/routing/mover.rb
55
55
  - lib/sidekiq/routing/parked_processor.rb
56
+ - lib/sidekiq/routing/queue_composition.rb
56
57
  - lib/sidekiq/routing/store.rb
57
58
  - lib/sidekiq/routing/sweeper.rb
58
59
  - lib/sidekiq/routing/version.rb