sidekiq-fusebox 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/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +129 -0
- data/Rakefile +12 -0
- data/lib/sidekiq/fusebox/circuit.rb +129 -0
- data/lib/sidekiq/fusebox/configuration.rb +26 -0
- data/lib/sidekiq/fusebox/middleware.rb +39 -0
- data/lib/sidekiq/fusebox/version.rb +7 -0
- data/lib/sidekiq/fusebox/web/views/index.html.erb +48 -0
- data/lib/sidekiq/fusebox/web.rb +44 -0
- data/lib/sidekiq/fusebox.rb +28 -0
- data/sig/sidekiq/fusebox.rbs +6 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: edd6428d9481a00060ac2bae28d6028d10c6339202c1e90ca47ae3a72e71bf53
|
|
4
|
+
data.tar.gz: 19e43a6860a8150defa625048e39ec41b685cdbae9a4532a4413c2f221a697e6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6564ada11b64db6068f7e07189f7858bbde43e279e5d3c5e08222706ee17fedef867d5172e3516f409d359aaa357fbc8938c34e2a8dc266dd3ae16af0cb3c61f
|
|
7
|
+
data.tar.gz: c14f4d2d72d36e5ac8bf58986a09ad36c71da93b4fb7b8a6662d4341b6eb0ac2f1bfd57a6a276253e8f10b311386a90730e5294717d13a774bb3ee489f15f681
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
- Add a Sidekiq Web UI tab ("Fusebox") listing every known target's circuit
|
|
4
|
+
state, with a manual "Force close" action.
|
|
5
|
+
- Add `Circuit.known_targets`, `Circuit.snapshot_all`, `Circuit#snapshot`,
|
|
6
|
+
and `Circuit#force_close!` to support it.
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-09-22
|
|
9
|
+
|
|
10
|
+
- Initial release
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TODO: Write your name
|
|
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,129 @@
|
|
|
1
|
+
# Sidekiq::Fusebox
|
|
2
|
+
|
|
3
|
+
A fuse box has one fuse per circuit — overload the microwave and its fuse
|
|
4
|
+
blows, but the fridge keeps running. `sidekiq-fusebox` brings that to
|
|
5
|
+
Sidekiq: an independent circuit breaker **per target**, so one flaky
|
|
6
|
+
downstream service fails fast without starving jobs bound for unrelated
|
|
7
|
+
targets.
|
|
8
|
+
|
|
9
|
+
Point it at any Sidekiq worker that talks to multiple external
|
|
10
|
+
systems — ERPs, payment gateways, webhooks, third-party APIs. When Brightpearl
|
|
11
|
+
starts timing out, only jobs targeting Brightpearl trip; jobs for NetSuite,
|
|
12
|
+
SAP, or anything else keep processing normally on the same worker threads.
|
|
13
|
+
|
|
14
|
+
## Why not just retry?
|
|
15
|
+
|
|
16
|
+
Sidekiq's built-in retries handle *transient* failures well. They don't
|
|
17
|
+
handle *sustained* outages: if a downstream service is down for 20 minutes,
|
|
18
|
+
every job aimed at it will still make a full-timeout API call, fail, and
|
|
19
|
+
retry — burning worker threads the whole time, and starving unrelated jobs
|
|
20
|
+
that share the same process. Fusebox adds a fast-fail layer in front of that:
|
|
21
|
+
once a target has failed enough times, jobs for that target raise
|
|
22
|
+
immediately, with **no external call**, until the target proves itself
|
|
23
|
+
healthy again.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bundle add sidekiq-fusebox
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Register the middleware and configure how to extract a "target" from each
|
|
34
|
+
job:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
Sidekiq.configure_server do |config|
|
|
38
|
+
config.server_middleware do |chain|
|
|
39
|
+
chain.add Sidekiq::Fusebox::Middleware
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Sidekiq::Fusebox.configure do |c|
|
|
44
|
+
# Required: how to derive the circuit's key from a job.
|
|
45
|
+
# Defaults to the worker class name if you don't set this.
|
|
46
|
+
c.extract_target = ->(_worker, job_payload) { job_payload["args"].first["integration"] }
|
|
47
|
+
|
|
48
|
+
# Only these exceptions count as a failure for the circuit. Narrow this to
|
|
49
|
+
# your integration's actual error classes — a broad StandardError rescue
|
|
50
|
+
# will also count your own bugs (e.g. a NoMethodError) as "the target is
|
|
51
|
+
# down", which is not what you want.
|
|
52
|
+
c.trip_on = [Faraday::TimeoutError, Faraday::ConnectionFailed]
|
|
53
|
+
|
|
54
|
+
c.failure_threshold = 5 # failures before the circuit opens
|
|
55
|
+
c.failure_window = 60 # seconds; failures older than this don't count
|
|
56
|
+
c.cooldown_period = 30 # seconds the circuit stays fully open
|
|
57
|
+
c.probe_lock_ttl = 30 # safety TTL on the single half-open probe
|
|
58
|
+
|
|
59
|
+
c.on_open = ->(target) { Rails.logger.warn("fusebox: circuit open for #{target}") }
|
|
60
|
+
c.on_close = ->(target) { Rails.logger.info("fusebox: circuit closed for #{target}") }
|
|
61
|
+
c.on_reject = ->(target, _job) { StatsD.increment("fusebox.rejected", tags: ["target:#{target}"]) }
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
class IntegrationSyncWorker
|
|
67
|
+
include Sidekiq::Job
|
|
68
|
+
|
|
69
|
+
def perform(order_id, integration:)
|
|
70
|
+
Integrations.for(integration).sync_order(order_id)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
Each target moves through three states, tracked in Redis:
|
|
78
|
+
|
|
79
|
+
- **closed** — healthy. Jobs run normally.
|
|
80
|
+
- **open** — `failure_threshold` failures happened within `failure_window`.
|
|
81
|
+
Every job for this target raises `Sidekiq::Fusebox::CircuitOpenError`
|
|
82
|
+
immediately, without being called, for `cooldown_period` seconds.
|
|
83
|
+
- **half-open** — the cooldown has elapsed. Exactly one job is let through as
|
|
84
|
+
a probe (via a Redis `SET NX` lock, so concurrent workers don't all hammer
|
|
85
|
+
the target at once). If it succeeds, the circuit closes. If it fails, the
|
|
86
|
+
circuit reopens for a fresh `cooldown_period`.
|
|
87
|
+
|
|
88
|
+
Because rejection raises `CircuitOpenError`, Sidekiq's own retry mechanism
|
|
89
|
+
schedules the retry with its normal backoff — Fusebox doesn't re-implement
|
|
90
|
+
scheduling. **Trade-off:** a rejection still consumes one of the job's
|
|
91
|
+
configured retry attempts. If you run targets with tight `retry:` counts,
|
|
92
|
+
account for open-circuit rejections when choosing that number, or rescue
|
|
93
|
+
`CircuitOpenError` specifically in a `sidekiq_retry_in` block to control its
|
|
94
|
+
backoff independently of your other failure types.
|
|
95
|
+
|
|
96
|
+
## Web UI
|
|
97
|
+
|
|
98
|
+
Fusebox adds a "Fusebox" tab to the Sidekiq Web UI listing every target
|
|
99
|
+
that's ever failed, its current state, failure count, and (while open) how
|
|
100
|
+
long until the next half-open probe — with a "Force close" button for manual
|
|
101
|
+
overrides. It's not loaded by `require "sidekiq/fusebox"`, since it pulls in
|
|
102
|
+
`sidekiq/web`; require it wherever you mount `Sidekiq::Web`:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# config.ru, or config/routes.rb in Rails
|
|
106
|
+
require "sidekiq/web"
|
|
107
|
+
require "sidekiq/fusebox/web"
|
|
108
|
+
|
|
109
|
+
run Sidekiq::Web # or: mount Sidekiq::Web => "/sidekiq"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
bin/setup
|
|
116
|
+
bundle exec rake # runs rspec + rubocop
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The spec suite stubs `Sidekiq.redis` with an in-memory fake
|
|
120
|
+
(`spec/support/fake_redis.rb`), so it doesn't need a real Redis server.
|
|
121
|
+
|
|
122
|
+
## Contributing
|
|
123
|
+
|
|
124
|
+
Bug reports and pull requests are welcome at
|
|
125
|
+
https://github.com/bhawsartanmay/sidekiq-fusebox.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module Fusebox
|
|
5
|
+
# A single per-target circuit, backed by three Redis keys namespaced
|
|
6
|
+
# under a hash tag so they always land on the same Redis Cluster slot,
|
|
7
|
+
# plus a global set that remembers every target that has ever failed
|
|
8
|
+
# (used by the Web UI to know what to list).
|
|
9
|
+
#
|
|
10
|
+
# fusebox:{target}:failures - rolling failure counter
|
|
11
|
+
# fusebox:{target}:opened_at - float timestamp, present only while open/half-open
|
|
12
|
+
# fusebox:{target}:probe_lock - claims the single half-open probe attempt
|
|
13
|
+
# fusebox:targets - set of every target that has ever failed
|
|
14
|
+
class Circuit
|
|
15
|
+
TARGETS_KEY = "fusebox:targets"
|
|
16
|
+
|
|
17
|
+
Snapshot = Struct.new(:target, :state, :failure_count, :opened_at, :cooldown_remaining, :probing,
|
|
18
|
+
keyword_init: true)
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def known_targets(config)
|
|
22
|
+
with_redis(config) { |conn| conn.call("SMEMBERS", TARGETS_KEY) }.sort
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def snapshot_all(config)
|
|
26
|
+
known_targets(config).map { |target| new(target, config).snapshot }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def with_redis(config, &block)
|
|
30
|
+
config.redis_pool ? config.redis_pool.with(&block) : Sidekiq.redis(&block)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(target, config)
|
|
35
|
+
@target = target
|
|
36
|
+
@config = config
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# true -> caller may proceed (circuit closed, or this job won the probe)
|
|
40
|
+
# false -> caller must not proceed (circuit open, or another job already probing)
|
|
41
|
+
def allow_request?
|
|
42
|
+
case state
|
|
43
|
+
when :closed then true
|
|
44
|
+
when :open then false
|
|
45
|
+
when :half_open then acquire_probe_lock
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record_success
|
|
50
|
+
removed = redis { |conn| conn.call("DEL", failures_key, opened_at_key, probe_lock_key) }
|
|
51
|
+
@config.on_close&.call(@target) if removed.to_i.positive?
|
|
52
|
+
end
|
|
53
|
+
alias force_close! record_success
|
|
54
|
+
|
|
55
|
+
def record_failure
|
|
56
|
+
redis { |conn| conn.call("SADD", TARGETS_KEY, @target) }
|
|
57
|
+
count = redis { |conn| conn.call("INCR", failures_key) }
|
|
58
|
+
redis { |conn| conn.call("EXPIRE", failures_key, @config.failure_window) }
|
|
59
|
+
return if count < @config.failure_threshold
|
|
60
|
+
|
|
61
|
+
open_circuit!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def state
|
|
65
|
+
derive_state(redis { |conn| conn.call("GET", opened_at_key) })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A read-only view of this circuit for display purposes (e.g. the Web UI).
|
|
69
|
+
def snapshot
|
|
70
|
+
opened_at = redis { |conn| conn.call("GET", opened_at_key) }
|
|
71
|
+
current_state = derive_state(opened_at)
|
|
72
|
+
|
|
73
|
+
Snapshot.new(
|
|
74
|
+
target: @target,
|
|
75
|
+
state: current_state,
|
|
76
|
+
failure_count: failure_count,
|
|
77
|
+
opened_at: opened_at ? Time.at(opened_at.to_f) : nil,
|
|
78
|
+
cooldown_remaining: cooldown_remaining(current_state, opened_at),
|
|
79
|
+
probing: probing?
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def derive_state(opened_at)
|
|
86
|
+
return :closed if opened_at.nil?
|
|
87
|
+
|
|
88
|
+
(Time.now.to_f - opened_at.to_f) < @config.cooldown_period ? :open : :half_open
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cooldown_remaining(current_state, opened_at)
|
|
92
|
+
return 0 unless current_state == :open
|
|
93
|
+
|
|
94
|
+
elapsed = Time.now.to_f - opened_at.to_f
|
|
95
|
+
(@config.cooldown_period - elapsed).round
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def failure_count
|
|
99
|
+
(redis { |conn| conn.call("GET", failures_key) } || 0).to_i
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def probing?
|
|
103
|
+
!redis { |conn| conn.call("GET", probe_lock_key) }.nil?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The PX TTL is a safety net so the key never lingers forever if
|
|
107
|
+
# record_success is never reached (worker killed mid-job, deploy, etc).
|
|
108
|
+
def open_circuit!
|
|
109
|
+
redis do |conn|
|
|
110
|
+
conn.call("SET", opened_at_key, Time.now.to_f.to_s, "PX", millis(@config.cooldown_period * 2))
|
|
111
|
+
conn.call("DEL", probe_lock_key)
|
|
112
|
+
end
|
|
113
|
+
@config.on_open&.call(@target)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def acquire_probe_lock
|
|
117
|
+
redis { |conn| conn.call("SET", probe_lock_key, "1", "NX", "PX", millis(@config.probe_lock_ttl)) } == "OK"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def millis(seconds) = (seconds * 1000).round
|
|
121
|
+
|
|
122
|
+
def redis(&block) = self.class.with_redis(@config, &block)
|
|
123
|
+
|
|
124
|
+
def failures_key = "fusebox:{#{@target}}:failures"
|
|
125
|
+
def opened_at_key = "fusebox:{#{@target}}:opened_at"
|
|
126
|
+
def probe_lock_key = "fusebox:{#{@target}}:probe_lock"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module Fusebox
|
|
5
|
+
# Holds the global defaults for every circuit. Override via
|
|
6
|
+
# Sidekiq::Fusebox.configure.
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_accessor :failure_threshold, :failure_window, :cooldown_period,
|
|
9
|
+
:probe_lock_ttl, :extract_target, :trip_on,
|
|
10
|
+
:on_open, :on_close, :on_reject, :redis_pool
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@failure_threshold = 5
|
|
14
|
+
@failure_window = 60
|
|
15
|
+
@cooldown_period = 30
|
|
16
|
+
@probe_lock_ttl = 30
|
|
17
|
+
@trip_on = [StandardError]
|
|
18
|
+
@extract_target = ->(_worker, job_payload) { job_payload["class"] }
|
|
19
|
+
@on_open = nil
|
|
20
|
+
@on_close = nil
|
|
21
|
+
@on_reject = nil
|
|
22
|
+
@redis_pool = nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module Fusebox
|
|
5
|
+
# Sidekiq server middleware: skips the job (fails fast, no external call)
|
|
6
|
+
# while the target's circuit is open, and feeds success/failure back into
|
|
7
|
+
# that target's circuit otherwise.
|
|
8
|
+
class Middleware
|
|
9
|
+
include Sidekiq::ServerMiddleware if defined?(Sidekiq::ServerMiddleware)
|
|
10
|
+
|
|
11
|
+
def call(job_instance, job_payload, _queue, &block)
|
|
12
|
+
config = Sidekiq::Fusebox.configuration
|
|
13
|
+
target = config.extract_target.call(job_instance, job_payload)
|
|
14
|
+
return block.call if target.nil?
|
|
15
|
+
|
|
16
|
+
circuit = Circuit.new(target, config)
|
|
17
|
+
reject!(config, target, job_payload) unless circuit.allow_request?
|
|
18
|
+
|
|
19
|
+
run(circuit, config, &block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def reject!(config, target, job_payload)
|
|
25
|
+
config.on_reject&.call(target, job_payload)
|
|
26
|
+
raise CircuitOpenError, "sidekiq-fusebox: circuit open for target=#{target.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run(circuit, config)
|
|
30
|
+
result = yield
|
|
31
|
+
circuit.record_success
|
|
32
|
+
result
|
|
33
|
+
rescue *config.trip_on => e
|
|
34
|
+
circuit.record_failure
|
|
35
|
+
raise e
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<section>
|
|
2
|
+
<header>
|
|
3
|
+
<h1>Fusebox Circuits</h1>
|
|
4
|
+
</header>
|
|
5
|
+
|
|
6
|
+
<div class="table_container">
|
|
7
|
+
<table>
|
|
8
|
+
<thead>
|
|
9
|
+
<tr>
|
|
10
|
+
<th>Target</th>
|
|
11
|
+
<th>State</th>
|
|
12
|
+
<th>Failures</th>
|
|
13
|
+
<th>Opened At</th>
|
|
14
|
+
<th>Cooldown Left</th>
|
|
15
|
+
<th>Probe</th>
|
|
16
|
+
<th>Actions</th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
<% if @targets.empty? %>
|
|
21
|
+
<tr>
|
|
22
|
+
<td colspan="7">No targets have failed yet.</td>
|
|
23
|
+
</tr>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% @targets.each do |t| %>
|
|
26
|
+
<tr>
|
|
27
|
+
<td><%= h t.target %></td>
|
|
28
|
+
<td>
|
|
29
|
+
<% label_class = {open: "label-danger", half_open: "label-warning", closed: "label-success"}.fetch(t.state) %>
|
|
30
|
+
<span class="label <%= label_class %>"><%= t.state.to_s.upcase.tr("_", "-") %></span>
|
|
31
|
+
</td>
|
|
32
|
+
<td class="num"><%= t.failure_count %></td>
|
|
33
|
+
<td><%= t.opened_at ? relative_time(t.opened_at) : "-" %></td>
|
|
34
|
+
<td class="num"><%= t.state == :open ? "#{t.cooldown_remaining}s" : "-" %></td>
|
|
35
|
+
<td><%= t.probing ? "probing now" : "-" %></td>
|
|
36
|
+
<td>
|
|
37
|
+
<form action="<%= root_path %>fusebox/<%= CGI.escape(t.target) %>/close" method="post">
|
|
38
|
+
<%= csrf_tag %>
|
|
39
|
+
<input class="btn btn-danger" type="submit" value="Force close"
|
|
40
|
+
data-confirm="Force-close the circuit for <%= h t.target %>?">
|
|
41
|
+
</form>
|
|
42
|
+
</td>
|
|
43
|
+
</tr>
|
|
44
|
+
<% end %>
|
|
45
|
+
</tbody>
|
|
46
|
+
</table>
|
|
47
|
+
</div>
|
|
48
|
+
</section>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sidekiq/web"
|
|
4
|
+
require "sidekiq/fusebox"
|
|
5
|
+
|
|
6
|
+
module Sidekiq
|
|
7
|
+
module Fusebox
|
|
8
|
+
# Adds a "Fusebox" tab to the Sidekiq Web UI listing every known target's
|
|
9
|
+
# circuit state, with a manual "Force close" action for ops overrides.
|
|
10
|
+
#
|
|
11
|
+
# Not required by "sidekiq/fusebox" itself. Wherever you mount Sidekiq::Web
|
|
12
|
+
# (typically config.ru, or config/routes.rb in Rails):
|
|
13
|
+
#
|
|
14
|
+
# require "sidekiq/web"
|
|
15
|
+
# require "sidekiq/fusebox/web"
|
|
16
|
+
#
|
|
17
|
+
module Web
|
|
18
|
+
VIEWS = File.expand_path("web/views", __dir__)
|
|
19
|
+
|
|
20
|
+
def self.registered(app)
|
|
21
|
+
app.get "/fusebox" do
|
|
22
|
+
@targets = Sidekiq::Fusebox::Circuit.snapshot_all(Sidekiq::Fusebox.configuration)
|
|
23
|
+
erb(File.read(File.join(VIEWS, "index.html.erb")))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
app.post "/fusebox/:target/close" do
|
|
27
|
+
target = route_params(:target)
|
|
28
|
+
Sidekiq::Fusebox::Circuit.new(target, Sidekiq::Fusebox.configuration).force_close!
|
|
29
|
+
redirect "#{root_path}fusebox"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if Sidekiq::Web.respond_to?(:configure)
|
|
37
|
+
Sidekiq::Web.configure do |cfg|
|
|
38
|
+
cfg.register(Sidekiq::Fusebox::Web, name: "fusebox", tab: "Fusebox", index: "fusebox")
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
# Older Sidekiq (< 8.0) had no Config#register; wire the tab up directly.
|
|
42
|
+
Sidekiq::Fusebox::Web.registered(Sidekiq::Web::Application)
|
|
43
|
+
Sidekiq::Web.tabs["Fusebox"] = "fusebox"
|
|
44
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sidekiq"
|
|
4
|
+
|
|
5
|
+
require_relative "fusebox/version"
|
|
6
|
+
require_relative "fusebox/configuration"
|
|
7
|
+
require_relative "fusebox/circuit"
|
|
8
|
+
require_relative "fusebox/middleware"
|
|
9
|
+
|
|
10
|
+
module Sidekiq
|
|
11
|
+
# A Sidekiq server middleware that trips an independent circuit breaker per
|
|
12
|
+
# target (extracted from job args), so one flaky downstream service fails
|
|
13
|
+
# fast without starving jobs bound for unrelated targets. See README.md.
|
|
14
|
+
module Fusebox
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
class CircuitOpenError < Error; end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def configuration
|
|
20
|
+
@configuration ||= Configuration.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def configure
|
|
24
|
+
yield(configuration)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sidekiq-fusebox
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tanmay Bhawsar
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: sidekiq
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
description: A Sidekiq server middleware that trips an independent circuit breaker
|
|
33
|
+
per target (extracted from job args, e.g. an integration or hostname), so one flaky
|
|
34
|
+
downstream service fails fast without starving jobs bound for unrelated targets.
|
|
35
|
+
email:
|
|
36
|
+
- tanmay.bhawsar@joshsoftware.com
|
|
37
|
+
executables: []
|
|
38
|
+
extensions: []
|
|
39
|
+
extra_rdoc_files: []
|
|
40
|
+
files:
|
|
41
|
+
- CHANGELOG.md
|
|
42
|
+
- LICENSE.txt
|
|
43
|
+
- README.md
|
|
44
|
+
- Rakefile
|
|
45
|
+
- lib/sidekiq/fusebox.rb
|
|
46
|
+
- lib/sidekiq/fusebox/circuit.rb
|
|
47
|
+
- lib/sidekiq/fusebox/configuration.rb
|
|
48
|
+
- lib/sidekiq/fusebox/middleware.rb
|
|
49
|
+
- lib/sidekiq/fusebox/version.rb
|
|
50
|
+
- lib/sidekiq/fusebox/web.rb
|
|
51
|
+
- lib/sidekiq/fusebox/web/views/index.html.erb
|
|
52
|
+
- sig/sidekiq/fusebox.rbs
|
|
53
|
+
homepage: https://github.com/bhawsartanmay/sidekiq-fusebox
|
|
54
|
+
licenses:
|
|
55
|
+
- MIT
|
|
56
|
+
metadata:
|
|
57
|
+
homepage_uri: https://github.com/bhawsartanmay/sidekiq-fusebox
|
|
58
|
+
source_code_uri: https://github.com/bhawsartanmay/sidekiq-fusebox.git
|
|
59
|
+
changelog_uri: https://github.com/bhawsartanmay/sidekiq-fusebox/blob/main/CHANGELOG.md
|
|
60
|
+
bug_tracker_uri: https://github.com/bhawsartanmay/sidekiq-fusebox/issues
|
|
61
|
+
rdoc_options: []
|
|
62
|
+
require_paths:
|
|
63
|
+
- lib
|
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 3.2.0
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 4.0.10
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: Per-target circuit breaker middleware for Sidekiq.
|
|
78
|
+
test_files: []
|