sidekiq-antidote 1.0.0.alpha.1
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/LICENSE.txt +21 -0
- data/README.adoc +104 -0
- data/lib/sidekiq/antidote/class_qualifier.rb +102 -0
- data/lib/sidekiq/antidote/config.rb +66 -0
- data/lib/sidekiq/antidote/inhibitor.rb +55 -0
- data/lib/sidekiq/antidote/middlewares/client.rb +20 -0
- data/lib/sidekiq/antidote/middlewares/server.rb +20 -0
- data/lib/sidekiq/antidote/middlewares/shared.rb +27 -0
- data/lib/sidekiq/antidote/remedy.rb +61 -0
- data/lib/sidekiq/antidote/repository.rb +92 -0
- data/lib/sidekiq/antidote/version.rb +7 -0
- data/lib/sidekiq/antidote/web.rb +57 -0
- data/lib/sidekiq/antidote.rb +128 -0
- data/web/locales/en.yml +8 -0
- data/web/views/add.html.erb +31 -0
- data/web/views/index.html.erb +34 -0
- metadata +92 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 052bc50b4ce029ead4b2a4e6253625cf9a82a9c4071be61945e76336d9d13f18
|
4
|
+
data.tar.gz: a12ac47db5f3edee4fba2e600b1da4029db87657a8bde540a7b3f2fb2c07911b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d9cf2a9e0db287b0b6c7d40a4fe99c7927dbb9aee9fdfd8efc06bdb81c91396f6b169271973746d2b543ac34673694c7894338d8c43d9f947f5f17659bafb4b8
|
7
|
+
data.tar.gz: 57d9306f0b40cbccd67a1cf9f5d7260ddb59098cf1078fca7aca4ae88df58b669e38e2a2dd6059cb4d1c48ecdbf35e2eb56ff55bcdf957f4955dca33f0dee102
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2023 Alexey Zapparov
|
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.adoc
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
= Sidekiq::Antidote
|
2
|
+
|
3
|
+
|
4
|
+
== Installation
|
5
|
+
|
6
|
+
Add this line to your application's Gemfile:
|
7
|
+
|
8
|
+
$ bundle add sidekiq-antidote
|
9
|
+
|
10
|
+
Or install it yourself as:
|
11
|
+
|
12
|
+
$ gem install sidekiq-antidote
|
13
|
+
|
14
|
+
|
15
|
+
== Usage
|
16
|
+
|
17
|
+
[source, ruby]
|
18
|
+
----
|
19
|
+
require "sidekiq"
|
20
|
+
require "sidekiq/antidote"
|
21
|
+
|
22
|
+
Sidekiq::Antidote.configure do |config|
|
23
|
+
# Set redis key prefix.
|
24
|
+
# Default: nil
|
25
|
+
config.key_prefix = "my-app:"
|
26
|
+
|
27
|
+
# Set inhibitors cache refresh rate in seconds.
|
28
|
+
# Default: 5.0
|
29
|
+
config.refresh_rate = 10.0
|
30
|
+
end
|
31
|
+
----
|
32
|
+
|
33
|
+
When running in forked environment (e.g., Puma web server), you also need to
|
34
|
+
call `Sidekiq::Antidote.startup` on fork:
|
35
|
+
|
36
|
+
[source, ruby]
|
37
|
+
----
|
38
|
+
# file: config/puma.rb
|
39
|
+
on_worker_boot { Sidekiq::Antidote.startup }
|
40
|
+
----
|
41
|
+
|
42
|
+
=== Web UI
|
43
|
+
|
44
|
+
Adding Antidote tab in Sidekiq web UI is as simple as:
|
45
|
+
|
46
|
+
[source, ruby]
|
47
|
+
----
|
48
|
+
require "sidekiq/web"
|
49
|
+
require "sidekiq/antidote/web"
|
50
|
+
----
|
51
|
+
|
52
|
+
|
53
|
+
== Supported Ruby Versions
|
54
|
+
|
55
|
+
This library aims to support and is tested against the following Ruby versions:
|
56
|
+
|
57
|
+
* Ruby 3.0.x
|
58
|
+
* Ruby 3.1.x
|
59
|
+
* Ruby 3.2.x
|
60
|
+
|
61
|
+
If something doesn't work on one of these versions, it's a bug.
|
62
|
+
|
63
|
+
This library may inadvertently work (or seem to work) on other Ruby versions,
|
64
|
+
however support will only be provided for the versions listed above.
|
65
|
+
|
66
|
+
If you would like this library to support another Ruby version or
|
67
|
+
implementation, you may volunteer to be a maintainer. Being a maintainer
|
68
|
+
entails making sure all tests run and pass on that implementation. When
|
69
|
+
something breaks on your implementation, you will be responsible for providing
|
70
|
+
patches in a timely fashion. If critical issues for a particular implementation
|
71
|
+
exist at the time of a major release, support for that Ruby version may be
|
72
|
+
dropped.
|
73
|
+
|
74
|
+
|
75
|
+
== Supported Sidekiq Versions
|
76
|
+
|
77
|
+
This library aims to support and work with following Sidekiq versions:
|
78
|
+
|
79
|
+
* Sidekiq 7.0.x
|
80
|
+
* Sidekiq 7.1.x
|
81
|
+
* Sidekiq 7.2.x
|
82
|
+
|
83
|
+
|
84
|
+
== Development
|
85
|
+
|
86
|
+
bundle install
|
87
|
+
bundle exec appraisal generate
|
88
|
+
bundle exec appraisal install
|
89
|
+
bundle exec rake
|
90
|
+
|
91
|
+
|
92
|
+
== Contributing
|
93
|
+
|
94
|
+
* Fork sidekiq-antidote
|
95
|
+
* Make your changes
|
96
|
+
* Ensure all tests pass (`bundle exec rake`)
|
97
|
+
* Send a merge request
|
98
|
+
* If we like them we'll merge them
|
99
|
+
* If we've accepted a patch, feel free to ask for commit access!
|
100
|
+
|
101
|
+
|
102
|
+
== Acknowledgement
|
103
|
+
|
104
|
+
* Inspired by https://github.com/square/sidekiq-killswitch[sidekiq-killswitch]
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
require "strscan"
|
5
|
+
require "stringio"
|
6
|
+
|
7
|
+
module Sidekiq
|
8
|
+
module Antidote
|
9
|
+
# Job display class pattern matcher. Some job classes may be represented by
|
10
|
+
# class and metehod name (e.g., `OnboardingMailer#welcome`) which is handled
|
11
|
+
# by this qualifier.
|
12
|
+
#
|
13
|
+
# ## Pattern Special Characters
|
14
|
+
#
|
15
|
+
# * `*` matches any number of alpha-numeric characters and underscores.
|
16
|
+
# Examples: `Foo`, `FooBar`, `method_name`
|
17
|
+
# * `**` matches any number of components.
|
18
|
+
# Examples: `Foo`, `Foo::Bar`, `Foo::Bar#method_name`
|
19
|
+
# * `{A,B,C}` matches literal `A`, `B`, or `C`.
|
20
|
+
class ClassQualifier
|
21
|
+
extend Forwardable
|
22
|
+
|
23
|
+
LITERAL = %r{(?:[a-z0-9_\#]|::)+}i
|
24
|
+
private_constant :LITERAL
|
25
|
+
|
26
|
+
WILDCARD = %r{\*+}
|
27
|
+
private_constant :WILDCARD
|
28
|
+
|
29
|
+
ALTERNATION = %r{\{[^*\{]+\}}
|
30
|
+
private_constant :ALTERNATION
|
31
|
+
|
32
|
+
# @return [String]
|
33
|
+
attr_reader :pattern
|
34
|
+
alias to_s pattern
|
35
|
+
|
36
|
+
# @return [Regexp]
|
37
|
+
attr_reader :regexp
|
38
|
+
|
39
|
+
# @!method match?(job_class)
|
40
|
+
# @param job_class [String]
|
41
|
+
# @return [Boolean]
|
42
|
+
def_delegator :regexp, :match?
|
43
|
+
|
44
|
+
# @param pattern [#to_s]
|
45
|
+
def initialize(pattern)
|
46
|
+
@pattern = pattern.to_s.strip.freeze
|
47
|
+
raise ArgumentError, "blank pattern" if @pattern.empty?
|
48
|
+
|
49
|
+
@regexp = build_regexp(@pattern)
|
50
|
+
|
51
|
+
freeze
|
52
|
+
end
|
53
|
+
|
54
|
+
# @param other [Object]
|
55
|
+
# @return [Boolean]
|
56
|
+
def eql?(other)
|
57
|
+
self.class == other.class && pattern == other.pattern
|
58
|
+
end
|
59
|
+
alias == eql?
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def build_regexp(pattern)
|
64
|
+
scanner = StringScanner.new(pattern)
|
65
|
+
parts = StringIO.new
|
66
|
+
|
67
|
+
until scanner.eos?
|
68
|
+
next if consume_literal(scanner, parts)
|
69
|
+
next if consume_wildcard(scanner, parts)
|
70
|
+
next if consume_alternation(scanner, parts)
|
71
|
+
|
72
|
+
raise ArgumentError, "invalid token #{scanner.peek(1)} at #{scanner.pos}: #{scanner.string.inspect}"
|
73
|
+
end
|
74
|
+
|
75
|
+
%r{\A#{parts.string}\z}i
|
76
|
+
end
|
77
|
+
|
78
|
+
def consume_literal(scanner, parts)
|
79
|
+
scanner.scan(LITERAL)&.then { parts << _1 }
|
80
|
+
end
|
81
|
+
|
82
|
+
def consume_wildcard(scanner, parts)
|
83
|
+
scanner.scan(WILDCARD)&.then do |wildcard|
|
84
|
+
case wildcard.length
|
85
|
+
when 1 then parts << "[a-z0-9_]*"
|
86
|
+
when 2 then parts << "(?:(?:\\#|::)?[a-z0-9_]+)*"
|
87
|
+
else
|
88
|
+
scanner.unscan
|
89
|
+
raise ArgumentError, "ambiguous wildcard #{wildcard} at #{scanner.pos}: #{scanner.string.inspect}"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def consume_alternation(scanner, parts)
|
95
|
+
scanner.scan(ALTERNATION)&.then do |alternation|
|
96
|
+
variants = alternation[1...-1].split(",").map { Regexp.escape(_1) }
|
97
|
+
parts << "(?:#{variants.join('|')})"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sidekiq
|
4
|
+
module Antidote
|
5
|
+
class Config
|
6
|
+
REDIS_KEY = "sidekiq-antidote"
|
7
|
+
private_constant :REDIS_KEY
|
8
|
+
|
9
|
+
# Default refresh rate
|
10
|
+
REFRESH_RATE = 5.0
|
11
|
+
|
12
|
+
# @return [String?]
|
13
|
+
attr_reader :key_prefix
|
14
|
+
|
15
|
+
# @return [Float]
|
16
|
+
attr_reader :refresh_rate
|
17
|
+
|
18
|
+
# Fully qualified Redis key
|
19
|
+
#
|
20
|
+
# @example Without key prefix (default)
|
21
|
+
# config.redis_key # => "sidekiq-antidote"
|
22
|
+
#
|
23
|
+
# @example With key prefix
|
24
|
+
# config.key_prefix = "foobar:"
|
25
|
+
# config.redis_key # => "foobar:sidekiq-antidote"
|
26
|
+
#
|
27
|
+
# @see #key_prefix
|
28
|
+
# @return [String]
|
29
|
+
attr_reader :redis_key
|
30
|
+
|
31
|
+
def initialize
|
32
|
+
@key_prefix = nil
|
33
|
+
@redis_key = REDIS_KEY
|
34
|
+
@refresh_rate = REFRESH_RATE
|
35
|
+
end
|
36
|
+
|
37
|
+
# Redis key prefix.
|
38
|
+
#
|
39
|
+
# @example
|
40
|
+
# config.key_prefix = "foobar:"
|
41
|
+
# config.redis_key # => "foobar:sidekiq-antidote"
|
42
|
+
#
|
43
|
+
# @see #redis_key
|
44
|
+
# @param value [String, nil] String that should be prepended to redis key
|
45
|
+
# @return [void]
|
46
|
+
def key_prefix=(value)
|
47
|
+
raise ArgumentError, "expected String, or nil; got #{value.class}" unless value.nil? || value.is_a?(String)
|
48
|
+
|
49
|
+
@redis_key = [value, REDIS_KEY].compact.join.freeze
|
50
|
+
@key_prefix = value&.then(&:-@) # Don't freeze original String value if it was unfrozen
|
51
|
+
end
|
52
|
+
|
53
|
+
# Inhibitors cache refresh rate in seconds.
|
54
|
+
#
|
55
|
+
# @param value [Float] refresh interval in seconds
|
56
|
+
# @return [void]
|
57
|
+
def refresh_rate=(value)
|
58
|
+
unless value.is_a?(Float) && value.positive?
|
59
|
+
raise ArgumentError, "expected positive Float; got #{value.inspect}"
|
60
|
+
end
|
61
|
+
|
62
|
+
@refresh_rate = value
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
|
5
|
+
require_relative "./class_qualifier"
|
6
|
+
|
7
|
+
module Sidekiq
|
8
|
+
module Antidote
|
9
|
+
# Single poison inhibition rule: class qualifier + treatment action.
|
10
|
+
class Inhibitor
|
11
|
+
TREATMENTS = %w[skip kill].freeze
|
12
|
+
|
13
|
+
# @return [String]
|
14
|
+
attr_reader :id
|
15
|
+
|
16
|
+
# @return ["skip", "kill"]
|
17
|
+
attr_reader :treatment
|
18
|
+
|
19
|
+
# @return [ClassQualifier]
|
20
|
+
attr_reader :class_qualifier
|
21
|
+
|
22
|
+
# @param id [#to_s]
|
23
|
+
# @param treatment ["skip", "kill"]
|
24
|
+
# @param class_qualifier [#to_s]
|
25
|
+
def initialize(id:, treatment:, class_qualifier:)
|
26
|
+
@id = -id.to_s
|
27
|
+
@treatment = -treatment.to_s
|
28
|
+
@class_qualifier = ClassQualifier.new(class_qualifier.to_s)
|
29
|
+
|
30
|
+
raise ArgumentError, "invalid id: #{id.inspect}" if @id.empty?
|
31
|
+
raise ArgumentError, "invalid treatment: #{treatment.inspect}" unless TREATMENTS.include?(@treatment)
|
32
|
+
|
33
|
+
freeze
|
34
|
+
end
|
35
|
+
|
36
|
+
def lethal?
|
37
|
+
"kill" == treatment
|
38
|
+
end
|
39
|
+
|
40
|
+
def match?(job_record)
|
41
|
+
class_qualifier.match?(job_record.display_class)
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_s
|
45
|
+
"#{treatment} #{class_qualifier}"
|
46
|
+
end
|
47
|
+
|
48
|
+
def eql?(other)
|
49
|
+
self.class == other.class \
|
50
|
+
&& id == other.id && treatment == other.treatment && class_qualifier == other.class_qualifier
|
51
|
+
end
|
52
|
+
alias == eql?
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "./shared"
|
4
|
+
|
5
|
+
module Sidekiq
|
6
|
+
module Antidote
|
7
|
+
module Middlewares
|
8
|
+
class Client
|
9
|
+
include Shared
|
10
|
+
include Sidekiq::ClientMiddleware
|
11
|
+
|
12
|
+
# @see https://github.com/sidekiq/sidekiq/wiki/Middleware
|
13
|
+
# @see https://github.com/sidekiq/sidekiq/wiki/Job-Format
|
14
|
+
def call(_, job_payload, _, _)
|
15
|
+
yield unless inhibit(job_payload)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "./shared"
|
4
|
+
|
5
|
+
module Sidekiq
|
6
|
+
module Antidote
|
7
|
+
module Middlewares
|
8
|
+
class Server
|
9
|
+
include Shared
|
10
|
+
include Sidekiq::ServerMiddleware
|
11
|
+
|
12
|
+
# @see https://github.com/sidekiq/sidekiq/wiki/Middleware
|
13
|
+
# @see https://github.com/sidekiq/sidekiq/wiki/Job-Format
|
14
|
+
def call(_, job_payload, _)
|
15
|
+
yield unless inhibit(job_payload)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sidekiq/api"
|
4
|
+
|
5
|
+
module Sidekiq
|
6
|
+
module Antidote
|
7
|
+
module Middlewares
|
8
|
+
module Shared
|
9
|
+
private
|
10
|
+
|
11
|
+
# @return [true] if message was inhibited
|
12
|
+
# @return [false] otherwise
|
13
|
+
def inhibit(message)
|
14
|
+
job_record = Sidekiq::JobRecord.new(message)
|
15
|
+
inhibitor = Antidote.remedy_for(job_record)
|
16
|
+
return false unless inhibitor
|
17
|
+
|
18
|
+
Antidote.log(:warn) { "I've got a poison! -- #{job_record.display_class}" }
|
19
|
+
Antidote.log(:warn) { "I've got a remedy! -- #{inhibitor}" }
|
20
|
+
DeadSet.new.kill(Sidekiq.dump_json(message)) if inhibitor.lethal?
|
21
|
+
|
22
|
+
true
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent"
|
4
|
+
|
5
|
+
module Sidekiq
|
6
|
+
module Antidote
|
7
|
+
# Eventually consistent list of inhibitors. Used by middlewares to avoid
|
8
|
+
# hitting Redis on every lookup.
|
9
|
+
class Remedy
|
10
|
+
include Enumerable
|
11
|
+
|
12
|
+
# @param refresh_rate [Float]
|
13
|
+
# @param repository [Repository]
|
14
|
+
def initialize(refresh_rate, repository:)
|
15
|
+
@inhibitors = [].freeze
|
16
|
+
@refresher = Concurrent::TimerTask.new(execution_interval: refresh_rate, run_now: true) do
|
17
|
+
@inhibitors = repository.to_a.freeze
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# @overload each
|
22
|
+
# @return [Enumerator<Inhibitor>]
|
23
|
+
#
|
24
|
+
# @overload each(&block)
|
25
|
+
# For a block { |inhibitor| ... }
|
26
|
+
# @yieldparam inhibitor [Inhibitor]
|
27
|
+
# @return [self]
|
28
|
+
def each(&block)
|
29
|
+
return to_enum __method__ unless block
|
30
|
+
|
31
|
+
start_refresher unless refresher_running?
|
32
|
+
@inhibitors.each(&block)
|
33
|
+
|
34
|
+
self
|
35
|
+
end
|
36
|
+
|
37
|
+
# Starts inhibitors list async poller.
|
38
|
+
#
|
39
|
+
# @return [self]
|
40
|
+
def start_refresher
|
41
|
+
@refresher.execute
|
42
|
+
self
|
43
|
+
end
|
44
|
+
|
45
|
+
# Stops inhibitors list async poller.
|
46
|
+
#
|
47
|
+
# @return [self]
|
48
|
+
def stop_refresher
|
49
|
+
@refresher.shutdown
|
50
|
+
self
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns whenever inhibitors list async poller is running.
|
54
|
+
#
|
55
|
+
# @return [Boolean]
|
56
|
+
def refresher_running?
|
57
|
+
@refresher.running?
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "./inhibitor"
|
4
|
+
|
5
|
+
module Sidekiq
|
6
|
+
module Antidote
|
7
|
+
# Inhibitors repository
|
8
|
+
class Repository
|
9
|
+
include Enumerable
|
10
|
+
|
11
|
+
# @param redis_key [#to_s]
|
12
|
+
def initialize(redis_key)
|
13
|
+
@redis_key = -redis_key.to_s
|
14
|
+
|
15
|
+
freeze
|
16
|
+
end
|
17
|
+
|
18
|
+
# @overload each
|
19
|
+
# @return [Enumerator<Inhibitor>]
|
20
|
+
#
|
21
|
+
# @overload each(&block)
|
22
|
+
# For a block { |inhibitor| ... }
|
23
|
+
# @yieldparam inhibitor [Inhibitor]
|
24
|
+
# @return [self]
|
25
|
+
def each
|
26
|
+
return to_enum __method__ unless block_given?
|
27
|
+
|
28
|
+
broken_ids = []
|
29
|
+
|
30
|
+
redis("HGETALL", @redis_key).each do |id, payload|
|
31
|
+
inhibitor = deserialize(id, payload)
|
32
|
+
next yield inhibitor if inhibitor
|
33
|
+
|
34
|
+
broken_ids << id
|
35
|
+
end
|
36
|
+
|
37
|
+
delete(*broken_ids)
|
38
|
+
self
|
39
|
+
end
|
40
|
+
|
41
|
+
# @param treatment (see Inhibitor#initialize)
|
42
|
+
# @param class_qualifier (see Inhibitor#initialize)
|
43
|
+
# @raise [RuntimeError] when can't generate new inhibitor ID
|
44
|
+
# @return [Inhibitor]
|
45
|
+
def add(treatment:, class_qualifier:)
|
46
|
+
3.times do
|
47
|
+
inhibitor = Sidekiq::Antidote::Inhibitor.new(
|
48
|
+
id: SecureRandom.hex(8),
|
49
|
+
treatment: treatment,
|
50
|
+
class_qualifier: class_qualifier
|
51
|
+
)
|
52
|
+
|
53
|
+
return inhibitor if redis("HSETNX", @redis_key, *serialize(inhibitor)).to_i.positive?
|
54
|
+
end
|
55
|
+
|
56
|
+
raise "can't generate available ID"
|
57
|
+
end
|
58
|
+
|
59
|
+
# @param ids [Array<String>]
|
60
|
+
# @return [nil]
|
61
|
+
def delete(*ids)
|
62
|
+
redis("HDEL", @redis_key, *ids) unless ids.empty?
|
63
|
+
nil
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def deserialize(id, payload)
|
69
|
+
treatment, class_qualifier = Sidekiq.load_json(payload)
|
70
|
+
|
71
|
+
Inhibitor.new(id: id, treatment: treatment, class_qualifier: class_qualifier)
|
72
|
+
rescue StandardError => e
|
73
|
+
Antidote.log(:error) { "failed deserializing inhibitor (#{payload.inspect}): #{e.message}" }
|
74
|
+
nil
|
75
|
+
end
|
76
|
+
|
77
|
+
def serialize(inhibitor)
|
78
|
+
[
|
79
|
+
inhibitor.id,
|
80
|
+
Sidekiq.dump_json([
|
81
|
+
inhibitor.treatment,
|
82
|
+
inhibitor.class_qualifier.pattern
|
83
|
+
])
|
84
|
+
]
|
85
|
+
end
|
86
|
+
|
87
|
+
def redis(...)
|
88
|
+
Sidekiq.redis { _1.call(...) }
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sidekiq"
|
4
|
+
require "sidekiq/web"
|
5
|
+
|
6
|
+
module Sidekiq
|
7
|
+
module Antidote
|
8
|
+
module Web
|
9
|
+
VIEWS = Pathname.new(__dir__).join("../../../web/views").expand_path
|
10
|
+
|
11
|
+
def self.registered(app) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
12
|
+
app.get("/antidote") do
|
13
|
+
@inhibitors = Antidote.inhibitors
|
14
|
+
|
15
|
+
erb(VIEWS.join("index.html.erb").read)
|
16
|
+
end
|
17
|
+
|
18
|
+
app.get("/antidote/add") do
|
19
|
+
@treatment = Sidekiq::Antidote::Inhibitor::TREATMENTS.first
|
20
|
+
@class_qualifier = ""
|
21
|
+
|
22
|
+
erb(VIEWS.join("add.html.erb").read)
|
23
|
+
end
|
24
|
+
|
25
|
+
app.post("/antidote/add") do
|
26
|
+
@treatment = params[:treatment]
|
27
|
+
@treatment = "skip" unless Sidekiq::Antidote::Inhibitor::TREATMENTS.include?(@treatment)
|
28
|
+
@class_qualifier = params[:class_qualifier]
|
29
|
+
@class_qualifier_error = nil
|
30
|
+
|
31
|
+
begin
|
32
|
+
Sidekiq::Antidote::ClassQualifier.new(@class_qualifier)
|
33
|
+
rescue StandardError => e
|
34
|
+
@class_qualifier_error = e.message
|
35
|
+
end
|
36
|
+
|
37
|
+
if @class_qualifier_error
|
38
|
+
erb(VIEWS.join("add.html.erb").read)
|
39
|
+
else
|
40
|
+
Antidote.add(treatment: @treatment, class_qualifier: @class_qualifier)
|
41
|
+
redirect "#{root_path}antidote"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
app.post("/antidote/:id/delete") do
|
46
|
+
Antidote.delete(route_params[:id])
|
47
|
+
|
48
|
+
redirect "#{root_path}antidote"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
Sidekiq::Web.register(Sidekiq::Antidote::Web)
|
56
|
+
Sidekiq::Web.tabs["Antidote"] = "antidote"
|
57
|
+
Sidekiq::Web.locales << Pathname.new(__dir__).join("../../../web/locales").expand_path.to_s
|
@@ -0,0 +1,128 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sidekiq"
|
4
|
+
require "sidekiq/api"
|
5
|
+
|
6
|
+
require_relative "./antidote/config"
|
7
|
+
require_relative "./antidote/inhibitor"
|
8
|
+
require_relative "./antidote/middlewares/client"
|
9
|
+
require_relative "./antidote/middlewares/server"
|
10
|
+
require_relative "./antidote/remedy"
|
11
|
+
require_relative "./antidote/repository"
|
12
|
+
require_relative "./antidote/version"
|
13
|
+
|
14
|
+
module Sidekiq
|
15
|
+
module Antidote
|
16
|
+
MUTEX = Mutex.new
|
17
|
+
|
18
|
+
@config = Config.new.freeze
|
19
|
+
@repository = Repository.new(@config.redis_key)
|
20
|
+
@remedy = Remedy.new(@config.refresh_rate, repository: @repository)
|
21
|
+
|
22
|
+
class << self
|
23
|
+
extend Forwardable
|
24
|
+
|
25
|
+
# @!method add(treatment:, class_qualifier:)
|
26
|
+
# @param (see Repository#add)
|
27
|
+
# @return (see Repository#add)
|
28
|
+
def_delegators :@repository, :add
|
29
|
+
|
30
|
+
# @!method delete(*ids)
|
31
|
+
# @param (see Repository#delete)
|
32
|
+
# @return (see Repository#delete)
|
33
|
+
def_delegators :@repository, :delete
|
34
|
+
|
35
|
+
# @!attribute [r] redis_key
|
36
|
+
# @return [String]
|
37
|
+
def_delegators :@config, :redis_key
|
38
|
+
|
39
|
+
# @return [Array<Inhibitor>] Live list of inhibitors
|
40
|
+
def inhibitors
|
41
|
+
@repository.to_a
|
42
|
+
end
|
43
|
+
|
44
|
+
# @api internal
|
45
|
+
# @param job_record [Sidekiq::JobRecord]
|
46
|
+
# @return [Inhibitor, nil]
|
47
|
+
def remedy_for(job_record)
|
48
|
+
@remedy.find { _1.match?(job_record) }
|
49
|
+
end
|
50
|
+
|
51
|
+
# Yields `config` for a block.
|
52
|
+
#
|
53
|
+
# @example
|
54
|
+
# Sidekiq::Antidote.configure do |config|
|
55
|
+
# config.refresh_rate = 42.0
|
56
|
+
# end
|
57
|
+
#
|
58
|
+
# @yieldparam config [Config]
|
59
|
+
def configure
|
60
|
+
MUTEX.synchronize do
|
61
|
+
config = @config.dup
|
62
|
+
|
63
|
+
yield config
|
64
|
+
|
65
|
+
@config = config.freeze
|
66
|
+
@repository = Repository.new(@config.redis_key)
|
67
|
+
|
68
|
+
self
|
69
|
+
ensure
|
70
|
+
reinit_remedy
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Starts inhibitors poller.
|
75
|
+
#
|
76
|
+
# @return [self]
|
77
|
+
def startup
|
78
|
+
MUTEX.synchronize { reinit_remedy.start_refresher }
|
79
|
+
|
80
|
+
self
|
81
|
+
end
|
82
|
+
|
83
|
+
# Shutdown inhibitors poller.
|
84
|
+
#
|
85
|
+
# @return [self]
|
86
|
+
def shutdown
|
87
|
+
MUTEX.synchronize { @remedy.stop_refresher }
|
88
|
+
|
89
|
+
self
|
90
|
+
end
|
91
|
+
|
92
|
+
# @api internal
|
93
|
+
#
|
94
|
+
# @return [nil]
|
95
|
+
def log(severity)
|
96
|
+
Sidekiq.logger.public_send(severity) { "sidekiq-antidote: #{yield}" }
|
97
|
+
nil
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def reinit_remedy
|
103
|
+
@remedy.stop_refresher
|
104
|
+
@remedy = Remedy.new(@config.refresh_rate, repository: @repository)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# TODO: How to test both configure_{client,server}?
|
110
|
+
configure_client do |config|
|
111
|
+
config.client_middleware do |chain|
|
112
|
+
chain.add Sidekiq::Antidote::Middlewares::Client
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
configure_server do |config|
|
117
|
+
config.on(:startup) { Antidote.startup }
|
118
|
+
config.on(:shutdown) { Antidote.shutdown }
|
119
|
+
|
120
|
+
config.client_middleware do |chain|
|
121
|
+
chain.add Sidekiq::Antidote::Middlewares::Client
|
122
|
+
end
|
123
|
+
|
124
|
+
config.server_middleware do |chain|
|
125
|
+
chain.add Sidekiq::Antidote::Middlewares::Server
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
data/web/locales/en.yml
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
<div class="header-container">
|
2
|
+
<h1><a href="<%=root_path %>antidote">Antidote</a> / <%= t("antidote.add") %></h1>
|
3
|
+
</div>
|
4
|
+
|
5
|
+
<% if @error %>
|
6
|
+
<div class="alert alert-danger"><%= @error %></div>
|
7
|
+
<% end %>
|
8
|
+
|
9
|
+
<form id="antidote-inhibitor" action="<%=root_path %>antidote/add" method="post">
|
10
|
+
<%= csrf_tag %>
|
11
|
+
<div class="form-row">
|
12
|
+
<div class="form-group col-md-2">
|
13
|
+
<label for="antidote-inhibitor-treatment"><%= t("antidote.treatment") %></label>
|
14
|
+
<select id="antidote-inhibitor-treatment" name="treatment" class="form-control">
|
15
|
+
<% Sidekiq::Antidote::Inhibitor::TREATMENTS.each do |treatment| %>
|
16
|
+
<option <%= "selected" if treatment == @treatment %>><%= treatment %></option>
|
17
|
+
<% end %>
|
18
|
+
</select>
|
19
|
+
</div>
|
20
|
+
<div class="form-group col-md-10">
|
21
|
+
<label for="antidote-inhibitor-class-qualifier"><%= t("antidote.qualifier") %></label>
|
22
|
+
<input id="antidote-inhibitor-class-qualifier" type="text" class="form-control <%= "is-invalid" if @class_qualifier_error %>" name="class_qualifier" value="<%= @class_qualifier %>">
|
23
|
+
<% if @class_qualifier_error %>
|
24
|
+
<div id="antidote-inhibitor-class-qualifier-error" class="invalid-feedback"><%= @class_qualifier_error %></div>
|
25
|
+
<% end %>
|
26
|
+
</div>
|
27
|
+
</div>
|
28
|
+
<div class="form-row text-right">
|
29
|
+
<button id="antidote-inhibitor-submit" type="submit" class="btn"><%= t("antidote.submit") %></button>
|
30
|
+
</div>
|
31
|
+
</form>
|
@@ -0,0 +1,34 @@
|
|
1
|
+
<div class="header-container">
|
2
|
+
<h1>Antidote</h1>
|
3
|
+
<div>
|
4
|
+
<a href="<%=root_path %>antidote/add" class="btn"><%= t("antidote.add") %></a>
|
5
|
+
</div>
|
6
|
+
</div>
|
7
|
+
|
8
|
+
<% if @inhibitors.empty? %>
|
9
|
+
<div class="alert alert-success"><%= t("antidote.no_inhibitors") %></div>
|
10
|
+
<% else %>
|
11
|
+
<div class="table_container">
|
12
|
+
<table class="antidote table table-hover table-bordered table-striped">
|
13
|
+
<thead>
|
14
|
+
<th><%= t("antidote.treatment") %></th>
|
15
|
+
<th><%= t("antidote.qualifier") %></th>
|
16
|
+
<th><%= t("antidote.actions") %></th>
|
17
|
+
</thead>
|
18
|
+
<tbody>
|
19
|
+
<% @inhibitors.each do |inhibitor| %>
|
20
|
+
<tr id="antidote-inhibitor-<%= CGI.escape(inhibitor.id) %>">
|
21
|
+
<td><%= inhibitor.treatment %></td>
|
22
|
+
<td><%= inhibitor.class_qualifier.pattern %></td>
|
23
|
+
<td class="delete-confirm">
|
24
|
+
<form action="<%=root_path %>antidote/<%= CGI.escape(inhibitor.id) %>/delete" method="post">
|
25
|
+
<%= csrf_tag %>
|
26
|
+
<input class="btn btn-danger" type="submit" name="delete" value="<%= t("Delete") %>" data-confirm="<%= t("AreYouSureDeleteQueue") %>" />
|
27
|
+
</form>
|
28
|
+
</td>
|
29
|
+
</tr>
|
30
|
+
<% end %>
|
31
|
+
</tbody>
|
32
|
+
</table>
|
33
|
+
</div>
|
34
|
+
<% end %>
|
metadata
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sidekiq-antidote
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0.alpha.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Alexey Zapparov
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-12-02 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: concurrent-ruby
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 1.2.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 1.2.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: sidekiq
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '7.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '7.0'
|
41
|
+
description:
|
42
|
+
email:
|
43
|
+
- alexey@zapparov.com
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- LICENSE.txt
|
49
|
+
- README.adoc
|
50
|
+
- lib/sidekiq/antidote.rb
|
51
|
+
- lib/sidekiq/antidote/class_qualifier.rb
|
52
|
+
- lib/sidekiq/antidote/config.rb
|
53
|
+
- lib/sidekiq/antidote/inhibitor.rb
|
54
|
+
- lib/sidekiq/antidote/middlewares/client.rb
|
55
|
+
- lib/sidekiq/antidote/middlewares/server.rb
|
56
|
+
- lib/sidekiq/antidote/middlewares/shared.rb
|
57
|
+
- lib/sidekiq/antidote/remedy.rb
|
58
|
+
- lib/sidekiq/antidote/repository.rb
|
59
|
+
- lib/sidekiq/antidote/version.rb
|
60
|
+
- lib/sidekiq/antidote/web.rb
|
61
|
+
- web/locales/en.yml
|
62
|
+
- web/views/add.html.erb
|
63
|
+
- web/views/index.html.erb
|
64
|
+
homepage: https://github.com/ixti/sidekiq-antidote
|
65
|
+
licenses:
|
66
|
+
- MIT
|
67
|
+
metadata:
|
68
|
+
homepage_uri: https://github.com/ixti/sidekiq-antidote
|
69
|
+
source_code_uri: https://github.com/ixti/sidekiq-antidote/tree/v1.0.0.alpha.1
|
70
|
+
bug_tracker_uri: https://github.com/ixti/sidekiq-antidote/issues
|
71
|
+
changelog_uri: https://github.com/ixti/sidekiq-antidote/blob/v1.0.0.alpha.1/CHANGES.md
|
72
|
+
rubygems_mfa_required: 'true'
|
73
|
+
post_install_message:
|
74
|
+
rdoc_options: []
|
75
|
+
require_paths:
|
76
|
+
- lib
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '3.0'
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
83
|
+
requirements:
|
84
|
+
- - ">"
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: 1.3.1
|
87
|
+
requirements: []
|
88
|
+
rubygems_version: 3.4.10
|
89
|
+
signing_key:
|
90
|
+
specification_version: 4
|
91
|
+
summary: Sidekiq poison-pill instant remedy
|
92
|
+
test_files: []
|