rubocop-sidekiq_plus 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/LICENSE.txt +21 -0
- data/README.md +322 -0
- data/config/default.yml +396 -0
- data/lib/rubocop/cop/sidekiq/active_record_argument.rb +77 -0
- data/lib/rubocop/cop/sidekiq/async_in_test.rb +42 -0
- data/lib/rubocop/cop/sidekiq/base.rb +14 -0
- data/lib/rubocop/cop/sidekiq/consistent_job_suffix.rb +44 -0
- data/lib/rubocop/cop/sidekiq/constant_job_class_name.rb +53 -0
- data/lib/rubocop/cop/sidekiq/database_connection_leak.rb +33 -0
- data/lib/rubocop/cop/sidekiq/date_time_argument.rb +80 -0
- data/lib/rubocop/cop/sidekiq/deprecated_default_worker_options.rb +44 -0
- data/lib/rubocop/cop/sidekiq/deprecated_delay_extension.rb +29 -0
- data/lib/rubocop/cop/sidekiq/deprecated_worker_module.rb +40 -0
- data/lib/rubocop/cop/sidekiq/enqueue_inefficiency.rb +75 -0
- data/lib/rubocop/cop/sidekiq/excessive_retry.rb +52 -0
- data/lib/rubocop/cop/sidekiq/find_each_in_job.rb +58 -0
- data/lib/rubocop/cop/sidekiq/huge_job_arguments.rb +73 -0
- data/lib/rubocop/cop/sidekiq/job_dependency.rb +30 -0
- data/lib/rubocop/cop/sidekiq/job_file_location.rb +52 -0
- data/lib/rubocop/cop/sidekiq/job_file_naming.rb +38 -0
- data/lib/rubocop/cop/sidekiq/job_include.rb +67 -0
- data/lib/rubocop/cop/sidekiq/missing_logging.rb +49 -0
- data/lib/rubocop/cop/sidekiq/missing_timeout.rb +65 -0
- data/lib/rubocop/cop/sidekiq/mixed_retry_strategies.rb +55 -0
- data/lib/rubocop/cop/sidekiq/mixin/argument_traversal.rb +28 -0
- data/lib/rubocop/cop/sidekiq/mixin/class_name_helper.rb +23 -0
- data/lib/rubocop/cop/sidekiq/mixin/processed_source_path.rb +19 -0
- data/lib/rubocop/cop/sidekiq/no_rescue_all.rb +70 -0
- data/lib/rubocop/cop/sidekiq/perform_inline_usage.rb +53 -0
- data/lib/rubocop/cop/sidekiq/perform_method_parameters.rb +53 -0
- data/lib/rubocop/cop/sidekiq/pii_in_arguments.rb +90 -0
- data/lib/rubocop/cop/sidekiq/puts_or_print_usage.rb +37 -0
- data/lib/rubocop/cop/sidekiq/queue_specified.rb +74 -0
- data/lib/rubocop/cop/sidekiq/redis_in_job.rb +32 -0
- data/lib/rubocop/cop/sidekiq/retry_specified.rb +81 -0
- data/lib/rubocop/cop/sidekiq/retry_zero.rb +48 -0
- data/lib/rubocop/cop/sidekiq/self_scheduling_job.rb +54 -0
- data/lib/rubocop/cop/sidekiq/sensitive_data_in_arguments.rb +92 -0
- data/lib/rubocop/cop/sidekiq/sidekiq_over_active_job.rb +30 -0
- data/lib/rubocop/cop/sidekiq/silent_rescue.rb +63 -0
- data/lib/rubocop/cop/sidekiq/sleep_in_jobs.rb +46 -0
- data/lib/rubocop/cop/sidekiq/symbol_argument.rb +69 -0
- data/lib/rubocop/cop/sidekiq/thread_in_job.rb +53 -0
- data/lib/rubocop/cop/sidekiq/transaction_leak.rb +72 -0
- data/lib/rubocop/cop/sidekiq/unknown_sidekiq_option.rb +52 -0
- data/lib/rubocop/cop/sidekiq_cops.rb +71 -0
- data/lib/rubocop/cop/sidekiq_ent/base.rb +43 -0
- data/lib/rubocop/cop/sidekiq_ent/encryption_with_many_arguments.rb +71 -0
- data/lib/rubocop/cop/sidekiq_ent/encryption_without_secret_bag.rb +83 -0
- data/lib/rubocop/cop/sidekiq_ent/leader_election_without_block.rb +75 -0
- data/lib/rubocop/cop/sidekiq_ent/limiter_not_reused.rb +79 -0
- data/lib/rubocop/cop/sidekiq_ent/limiter_without_lock_timeout.rb +46 -0
- data/lib/rubocop/cop/sidekiq_ent/limiter_without_wait_timeout.rb +49 -0
- data/lib/rubocop/cop/sidekiq_ent/periodic_job_invalid_cron.rb +108 -0
- data/lib/rubocop/cop/sidekiq_ent/periodic_job_with_arguments.rb +94 -0
- data/lib/rubocop/cop/sidekiq_ent/unique_job_too_short_ttl.rb +80 -0
- data/lib/rubocop/cop/sidekiq_ent/unique_job_without_ttl.rb +52 -0
- data/lib/rubocop/cop/sidekiq_ent/unique_until_mismatch.rb +59 -0
- data/lib/rubocop/cop/sidekiq_pro/base.rb +39 -0
- data/lib/rubocop/cop/sidekiq_pro/batch_callback_method.rb +66 -0
- data/lib/rubocop/cop/sidekiq_pro/batch_retry_in_callback.rb +54 -0
- data/lib/rubocop/cop/sidekiq_pro/batch_status_polling.rb +68 -0
- data/lib/rubocop/cop/sidekiq_pro/batch_without_callback.rb +92 -0
- data/lib/rubocop/cop/sidekiq_pro/empty_batch.rb +108 -0
- data/lib/rubocop/cop/sidekiq_pro/expiring_job_without_ttl.rb +101 -0
- data/lib/rubocop/cop/sidekiq_pro/large_argument_in_batch.rb +93 -0
- data/lib/rubocop/cop/sidekiq_pro/nested_batch_without_parent.rb +55 -0
- data/lib/rubocop/cop/sidekiq_pro/reliability_not_enabled.rb +67 -0
- data/lib/rubocop/sidekiq/config_formatter.rb +60 -0
- data/lib/rubocop/sidekiq/description_extractor.rb +70 -0
- data/lib/rubocop/sidekiq/language.rb +79 -0
- data/lib/rubocop/sidekiq/plugin.rb +30 -0
- data/lib/rubocop/sidekiq/version.rb +7 -0
- data/lib/rubocop/sidekiq.rb +10 -0
- data/lib/rubocop-sidekiq.rb +5 -0
- data/lib/rubocop-sidekiq_plus.rb +9 -0
- metadata +150 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SidekiqEnt
|
|
6
|
+
# Checks that encrypted jobs use proper argument structure.
|
|
7
|
+
#
|
|
8
|
+
# Sidekiq Enterprise encryption only encrypts the last argument.
|
|
9
|
+
# If sensitive data is passed in non-last arguments, it won't be encrypted.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# # bad - sensitive data not in last argument
|
|
13
|
+
# class MyJob
|
|
14
|
+
# include Sidekiq::Job
|
|
15
|
+
# sidekiq_options encrypt: true
|
|
16
|
+
#
|
|
17
|
+
# def perform(password, user_id, options)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# # good - sensitive data in last argument (secret bag)
|
|
22
|
+
# class MyJob
|
|
23
|
+
# include Sidekiq::Job
|
|
24
|
+
# sidekiq_options encrypt: true
|
|
25
|
+
#
|
|
26
|
+
# def perform(user_id, secret_bag)
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
class EncryptionWithManyArguments < Base
|
|
31
|
+
include RuboCop::Sidekiq::Language
|
|
32
|
+
|
|
33
|
+
MSG = 'Encrypted jobs should use a secret bag pattern. ' \
|
|
34
|
+
'Only the last argument is encrypted; consider consolidating sensitive data.'
|
|
35
|
+
|
|
36
|
+
MAX_RECOMMENDED_ARGS = 2
|
|
37
|
+
|
|
38
|
+
# @!method encryption_enabled?(node)
|
|
39
|
+
def_node_matcher :encryption_enabled?, <<~PATTERN
|
|
40
|
+
(send nil? :sidekiq_options (hash <(pair (sym :encrypt) {(true) (sym :true)}) ...>))
|
|
41
|
+
PATTERN
|
|
42
|
+
|
|
43
|
+
def on_send(node)
|
|
44
|
+
return unless encryption_enabled?(node)
|
|
45
|
+
|
|
46
|
+
class_node = node.each_ancestor(:class).first
|
|
47
|
+
return unless class_node
|
|
48
|
+
|
|
49
|
+
perform_method = find_perform_method(class_node)
|
|
50
|
+
return unless perform_method
|
|
51
|
+
|
|
52
|
+
arg_count = perform_method.arguments.size
|
|
53
|
+
add_offense(perform_method.loc.name) if arg_count > max_args
|
|
54
|
+
end
|
|
55
|
+
alias on_csend on_send
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def max_args
|
|
60
|
+
cop_config.fetch('MaxArguments', MAX_RECOMMENDED_ARGS)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def find_perform_method(class_node)
|
|
64
|
+
class_node.body&.each_descendant(:def)&.find do |def_node|
|
|
65
|
+
def_node.method?(:perform)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SidekiqEnt
|
|
6
|
+
# Checks that encrypted jobs have meaningful arguments to encrypt.
|
|
7
|
+
#
|
|
8
|
+
# Sidekiq Enterprise encryption only encrypts the last argument.
|
|
9
|
+
# If the job only has a single ID-like argument, encryption may
|
|
10
|
+
# not provide much value.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# # questionable - only ID argument
|
|
14
|
+
# class MyJob
|
|
15
|
+
# include Sidekiq::Job
|
|
16
|
+
# sidekiq_options encrypt: true
|
|
17
|
+
#
|
|
18
|
+
# def perform(user_id)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # good - secret data in last argument
|
|
23
|
+
# class MyJob
|
|
24
|
+
# include Sidekiq::Job
|
|
25
|
+
# sidekiq_options encrypt: true
|
|
26
|
+
#
|
|
27
|
+
# def perform(user_id, secret_data)
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
class EncryptionWithoutSecretBag < Base
|
|
32
|
+
include RuboCop::Sidekiq::Language
|
|
33
|
+
|
|
34
|
+
MSG = 'Encrypted job has only one argument. ' \
|
|
35
|
+
'Consider if encryption is necessary or add a secret bag argument.'
|
|
36
|
+
|
|
37
|
+
# @!method encryption_enabled?(node)
|
|
38
|
+
def_node_matcher :encryption_enabled?, <<~PATTERN
|
|
39
|
+
(send nil? :sidekiq_options (hash <(pair (sym :encrypt) {(true) (sym :true)}) ...>))
|
|
40
|
+
PATTERN
|
|
41
|
+
|
|
42
|
+
def on_send(node)
|
|
43
|
+
return unless encryption_enabled?(node)
|
|
44
|
+
|
|
45
|
+
class_node = node.each_ancestor(:class).first
|
|
46
|
+
return unless class_node
|
|
47
|
+
|
|
48
|
+
perform_method = find_perform_method(class_node)
|
|
49
|
+
return unless perform_method
|
|
50
|
+
|
|
51
|
+
add_offense(perform_method.loc.name) if single_id_like_argument?(perform_method)
|
|
52
|
+
end
|
|
53
|
+
alias on_csend on_send
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def find_perform_method(class_node)
|
|
58
|
+
class_node.body&.each_descendant(:def)&.find do |def_node|
|
|
59
|
+
def_node.method?(:perform)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def single_id_like_argument?(method_node)
|
|
64
|
+
args = method_node.arguments
|
|
65
|
+
return false unless args.size == 1
|
|
66
|
+
|
|
67
|
+
arg_name = args.first.name.to_s
|
|
68
|
+
id_like_patterns.any? { |pattern| arg_name.match?(pattern) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def id_like_patterns
|
|
72
|
+
@id_like_patterns ||= [
|
|
73
|
+
/_id\z/,
|
|
74
|
+
/\Aid\z/,
|
|
75
|
+
/_ids\z/,
|
|
76
|
+
/\Auuid\z/,
|
|
77
|
+
/\Arecord_id\z/
|
|
78
|
+
]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SidekiqEnt
|
|
6
|
+
# Checks for potentially problematic leader election usage.
|
|
7
|
+
#
|
|
8
|
+
# Using `Sidekiq.leader?` for long-running operations can be
|
|
9
|
+
# problematic if leadership changes during execution. Prefer
|
|
10
|
+
# delegating work to a job.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# # bad - long-running operation in leader check
|
|
14
|
+
# if Sidekiq.leader?
|
|
15
|
+
# do_long_running_work
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# # good - enqueue job for leader work
|
|
19
|
+
# if Sidekiq.leader?
|
|
20
|
+
# LeaderOnlyJob.perform_async
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
class LeaderElectionWithoutBlock < Base
|
|
24
|
+
MSG = 'Avoid long-running operations in leader checks. ' \
|
|
25
|
+
'Consider delegating work to a job.'
|
|
26
|
+
|
|
27
|
+
# @!method leader_check?(node)
|
|
28
|
+
def_node_matcher :leader_check?, <<~PATTERN
|
|
29
|
+
(send (const {nil? cbase} :Sidekiq) :leader?)
|
|
30
|
+
PATTERN
|
|
31
|
+
|
|
32
|
+
# @!method if_leader_condition?(node)
|
|
33
|
+
def_node_matcher :if_leader_condition?, <<~PATTERN
|
|
34
|
+
(if (send (const {nil? cbase} :Sidekiq) :leader?) $_ $_)
|
|
35
|
+
PATTERN
|
|
36
|
+
|
|
37
|
+
def on_if(node)
|
|
38
|
+
if_leader_condition?(node) do |then_branch, _else_branch|
|
|
39
|
+
return unless then_branch
|
|
40
|
+
|
|
41
|
+
add_offense(node) if contains_non_job_calls?(then_branch)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def contains_non_job_calls?(node)
|
|
48
|
+
return false if only_job_enqueue?(node)
|
|
49
|
+
|
|
50
|
+
case node.type
|
|
51
|
+
when :begin
|
|
52
|
+
node.children.any? { |child| contains_non_job_calls?(child) }
|
|
53
|
+
when :send
|
|
54
|
+
!job_enqueue_call?(node)
|
|
55
|
+
else
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def only_job_enqueue?(node)
|
|
61
|
+
return job_enqueue_call?(node) if node.send_type?
|
|
62
|
+
return node.children.all? { |child| child.send_type? && job_enqueue_call?(child) } if node.begin_type?
|
|
63
|
+
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def job_enqueue_call?(node)
|
|
68
|
+
return false unless node.send_type?
|
|
69
|
+
|
|
70
|
+
%i[perform_async perform_in perform_at].include?(node.method_name)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SidekiqEnt
|
|
6
|
+
# Checks that rate limiters are created as class constants for reuse.
|
|
7
|
+
#
|
|
8
|
+
# Creating limiters inside the perform method causes Redis memory leaks
|
|
9
|
+
# because each instance creates new Redis keys. Limiters should be
|
|
10
|
+
# defined as class constants to be reused across job executions.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# # bad - limiter created inside perform
|
|
14
|
+
# class MyJob
|
|
15
|
+
# include Sidekiq::Job
|
|
16
|
+
#
|
|
17
|
+
# def perform
|
|
18
|
+
# limiter = Sidekiq::Limiter.concurrent('api', 50)
|
|
19
|
+
# limiter.within_limit { call_api }
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# # good - limiter as class constant
|
|
24
|
+
# class MyJob
|
|
25
|
+
# include Sidekiq::Job
|
|
26
|
+
# API_LIMITER = Sidekiq::Limiter.concurrent('api', 50, wait_timeout: 0)
|
|
27
|
+
#
|
|
28
|
+
# def perform
|
|
29
|
+
# API_LIMITER.within_limit { call_api }
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# # good - dynamic limiter name (user-specific)
|
|
34
|
+
# class MyJob
|
|
35
|
+
# include Sidekiq::Job
|
|
36
|
+
#
|
|
37
|
+
# def perform(user_id)
|
|
38
|
+
# limiter = Sidekiq::Limiter.concurrent("api-#{user_id}", 10)
|
|
39
|
+
# limiter.within_limit { call_api_for_user(user_id) }
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
class LimiterNotReused < Base
|
|
44
|
+
MSG = 'Create rate limiters as class constants for reuse.'
|
|
45
|
+
|
|
46
|
+
def on_send(node)
|
|
47
|
+
limiter_creation?(node) do |_method, name, _limit|
|
|
48
|
+
return if inside_class_body_directly?(node)
|
|
49
|
+
return if dynamic_limiter_name?(name)
|
|
50
|
+
|
|
51
|
+
add_offense(node)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
alias on_csend on_send
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def inside_class_body_directly?(node)
|
|
59
|
+
!inside_method?(node) && inside_class?(node)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def inside_method?(node)
|
|
63
|
+
node.each_ancestor(:any_def).any?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inside_class?(node)
|
|
67
|
+
node.each_ancestor(:class).any?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def dynamic_limiter_name?(name)
|
|
71
|
+
return true if name.dstr_type?
|
|
72
|
+
return true if name.send_type?
|
|
73
|
+
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SidekiqEnt
|
|
6
|
+
# Checks that concurrent limiters specify lock_timeout option.
|
|
7
|
+
#
|
|
8
|
+
# Without lock_timeout, the default (30 seconds) may not cover jobs
|
|
9
|
+
# with longer execution times, causing locks to expire prematurely.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# # bad - no lock_timeout specified
|
|
13
|
+
# LIMITER = Sidekiq::Limiter.concurrent('erp', 50, wait_timeout: 0)
|
|
14
|
+
#
|
|
15
|
+
# # good - explicit lock_timeout
|
|
16
|
+
# LIMITER = Sidekiq::Limiter.concurrent('erp', 50, wait_timeout: 0, lock_timeout: 120)
|
|
17
|
+
#
|
|
18
|
+
class LimiterWithoutLockTimeout < Base
|
|
19
|
+
MSG = 'Specify `lock_timeout` option for concurrent limiters to match job execution time.'
|
|
20
|
+
|
|
21
|
+
def on_send(node)
|
|
22
|
+
limiter_creation?(node) do |method, _name, _limit|
|
|
23
|
+
return unless method == :concurrent
|
|
24
|
+
return if lock_timeout_option?(node)
|
|
25
|
+
|
|
26
|
+
add_offense(node)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
alias on_csend on_send
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def lock_timeout_option?(node)
|
|
34
|
+
options_hash = find_options_hash(node)
|
|
35
|
+
return false unless options_hash
|
|
36
|
+
|
|
37
|
+
options_hash.pairs.any? { |pair| pair.key.value == :lock_timeout }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def find_options_hash(node)
|
|
41
|
+
node.arguments.find(&:hash_type?)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SidekiqEnt
|
|
6
|
+
# Checks that rate limiters specify wait_timeout option.
|
|
7
|
+
#
|
|
8
|
+
# Without wait_timeout, jobs will wait indefinitely for a limit slot,
|
|
9
|
+
# potentially blocking Sidekiq worker threads. Setting wait_timeout: 0
|
|
10
|
+
# for class constant limiters makes the job fail fast and rely on retry.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# # bad - no wait_timeout specified
|
|
14
|
+
# API_LIMITER = Sidekiq::Limiter.concurrent('api', 50)
|
|
15
|
+
#
|
|
16
|
+
# # good - explicit wait_timeout
|
|
17
|
+
# API_LIMITER = Sidekiq::Limiter.concurrent('api', 50, wait_timeout: 0)
|
|
18
|
+
#
|
|
19
|
+
# # good - wait_timeout with value
|
|
20
|
+
# API_LIMITER = Sidekiq::Limiter.concurrent('api', 50, wait_timeout: 5)
|
|
21
|
+
#
|
|
22
|
+
class LimiterWithoutWaitTimeout < Base
|
|
23
|
+
MSG = 'Specify `wait_timeout` option for rate limiters to avoid blocking worker threads.'
|
|
24
|
+
|
|
25
|
+
def on_send(node)
|
|
26
|
+
limiter_creation?(node) do |_method, _name, _limit|
|
|
27
|
+
return if wait_timeout_option?(node)
|
|
28
|
+
|
|
29
|
+
add_offense(node)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
alias on_csend on_send
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def wait_timeout_option?(node)
|
|
37
|
+
options_hash = find_options_hash(node)
|
|
38
|
+
return false unless options_hash
|
|
39
|
+
|
|
40
|
+
options_hash.pairs.any? { |pair| pair.key.value == :wait_timeout }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def find_options_hash(node)
|
|
44
|
+
node.arguments.find(&:hash_type?)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SidekiqEnt
|
|
6
|
+
# Checks that cron expressions in periodic job registration are valid.
|
|
7
|
+
#
|
|
8
|
+
# Invalid cron expressions will cause Sidekiq Enterprise to fail when
|
|
9
|
+
# loading the periodic job configuration.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# # bad - invalid cron (6 fields)
|
|
13
|
+
# config.periodic do |mgr|
|
|
14
|
+
# mgr.register('0 * * * * *', 'SomeJob')
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# # bad - invalid minute value
|
|
18
|
+
# config.periodic do |mgr|
|
|
19
|
+
# mgr.register('60 * * * *', 'SomeJob')
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # good - valid cron
|
|
23
|
+
# config.periodic do |mgr|
|
|
24
|
+
# mgr.register('0 * * * *', 'SomeJob')
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
class PeriodicJobInvalidCron < Base
|
|
28
|
+
MSG = 'Invalid cron expression: %<reason>s'
|
|
29
|
+
|
|
30
|
+
# @!method periodic_register?(node)
|
|
31
|
+
def_node_matcher :periodic_register?, <<~PATTERN
|
|
32
|
+
(send _ :register (str $_) ...)
|
|
33
|
+
PATTERN
|
|
34
|
+
|
|
35
|
+
def on_send(node)
|
|
36
|
+
periodic_register?(node) do |cron_expression|
|
|
37
|
+
error = validate_cron(cron_expression)
|
|
38
|
+
add_offense(node.first_argument, message: format(MSG, reason: error)) if error
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
alias on_csend on_send
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def validate_cron(expression)
|
|
46
|
+
fields = expression.strip.split(/\s+/)
|
|
47
|
+
|
|
48
|
+
return 'expected 5 fields (minute hour day month weekday)' unless fields.size == 5
|
|
49
|
+
|
|
50
|
+
validate_field(fields[0], 0, 59, 'minute') ||
|
|
51
|
+
validate_field(fields[1], 0, 23, 'hour') ||
|
|
52
|
+
validate_field(fields[2], 1, 31, 'day') ||
|
|
53
|
+
validate_field(fields[3], 1, 12, 'month') ||
|
|
54
|
+
validate_field(fields[4], 0, 6, 'weekday')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_field(field, min, max, name)
|
|
58
|
+
return nil if field == '*'
|
|
59
|
+
|
|
60
|
+
if field.include?('/')
|
|
61
|
+
step_validation(field, min, max, name)
|
|
62
|
+
elsif field.include?(',')
|
|
63
|
+
list_validation(field, min, max, name)
|
|
64
|
+
elsif field.include?('-')
|
|
65
|
+
range_validation(field, min, max, name)
|
|
66
|
+
else
|
|
67
|
+
value_validation(field, min, max, name)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def step_validation(field, min, max, name)
|
|
72
|
+
base, step = field.split('/', 2)
|
|
73
|
+
base_error = base == '*' ? nil : validate_field(base, min, max, name)
|
|
74
|
+
return base_error if base_error
|
|
75
|
+
return "invalid step value for #{name}" unless step&.match?(/\A\d+\z/)
|
|
76
|
+
return "step value out of range for #{name}" unless step.to_i.between?(1, max)
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def list_validation(field, min, max, name)
|
|
82
|
+
field.split(',').each do |value|
|
|
83
|
+
error = validate_field(value.strip, min, max, name)
|
|
84
|
+
return error if error
|
|
85
|
+
end
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def range_validation(field, min, max, name)
|
|
90
|
+
start_val, end_val = field.split('-', 2)
|
|
91
|
+
return "invalid range for #{name}" unless start_val&.match?(/\A\d+\z/) && end_val&.match?(/\A\d+\z/)
|
|
92
|
+
|
|
93
|
+
in_range = start_val.to_i.between?(min, max) && end_val.to_i.between?(min, max)
|
|
94
|
+
return "#{name} value out of range" unless in_range
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def value_validation(field, min, max, name)
|
|
100
|
+
return "invalid #{name} value" unless field.match?(/\A\d+\z/)
|
|
101
|
+
return "#{name} value out of range (#{min}-#{max})" unless field.to_i.between?(min, max)
|
|
102
|
+
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SidekiqEnt
|
|
6
|
+
# Checks that periodic jobs do not require arguments without defaults.
|
|
7
|
+
#
|
|
8
|
+
# Periodic jobs are scheduled by cron and cannot receive dynamic arguments
|
|
9
|
+
# unless specified via the `args` option in the periodic registration.
|
|
10
|
+
# A perform method requiring arguments will fail when called periodically.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# # bad - requires arguments
|
|
14
|
+
# class HourlyReportJob
|
|
15
|
+
# include Sidekiq::Job
|
|
16
|
+
#
|
|
17
|
+
# def perform(user_id)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# # good - no arguments
|
|
22
|
+
# class HourlyReportJob
|
|
23
|
+
# include Sidekiq::Job
|
|
24
|
+
#
|
|
25
|
+
# def perform
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# # good - optional arguments with defaults
|
|
30
|
+
# class HourlyReportJob
|
|
31
|
+
# include Sidekiq::Job
|
|
32
|
+
#
|
|
33
|
+
# def perform(scope = 'all')
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
class PeriodicJobWithArguments < Base
|
|
38
|
+
include RuboCop::Sidekiq::Language
|
|
39
|
+
|
|
40
|
+
MSG = 'Periodic job `perform` should not require arguments. ' \
|
|
41
|
+
'Use optional arguments or the `args` option in periodic registration.'
|
|
42
|
+
|
|
43
|
+
# @!method periodic_register?(node)
|
|
44
|
+
def_node_matcher :periodic_register?, <<~PATTERN
|
|
45
|
+
(send _ :register (str _) {(str $_) (const ... $_)} ...)
|
|
46
|
+
PATTERN
|
|
47
|
+
|
|
48
|
+
def on_send(node)
|
|
49
|
+
periodic_register?(node) do |job_class_name|
|
|
50
|
+
job_class_name = job_class_name.to_s
|
|
51
|
+
check_job_class(node, job_class_name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
alias on_csend on_send
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def check_job_class(register_node, job_class_name)
|
|
59
|
+
return if args_option?(register_node)
|
|
60
|
+
|
|
61
|
+
find_job_class(job_class_name)&.then do |class_node|
|
|
62
|
+
perform_method = find_perform_method(class_node)
|
|
63
|
+
add_offense(perform_method.loc.name) if perform_method && requires_arguments?(perform_method)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def args_option?(node)
|
|
68
|
+
options_hash = node.arguments.find(&:hash_type?)
|
|
69
|
+
return false unless options_hash
|
|
70
|
+
|
|
71
|
+
options_hash.pairs.any? { |pair| pair.key.value == :args }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def find_job_class(class_name)
|
|
75
|
+
processed_source.ast.each_descendant(:class).find do |class_node|
|
|
76
|
+
class_node.identifier.source == class_name.split('::').last
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def find_perform_method(class_node)
|
|
81
|
+
class_node.body&.each_descendant(:def)&.find do |def_node|
|
|
82
|
+
def_node.method?(:perform)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def requires_arguments?(method_node)
|
|
87
|
+
method_node.arguments.any? do |arg|
|
|
88
|
+
arg.type?(:arg, :kwarg)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module SidekiqEnt
|
|
6
|
+
# Checks that unique jobs have sufficient TTL.
|
|
7
|
+
#
|
|
8
|
+
# A too short unique_for TTL may expire during retries, allowing
|
|
9
|
+
# duplicate jobs to be enqueued while the original is still retrying.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# # bad - TTL too short
|
|
13
|
+
# class MyJob
|
|
14
|
+
# include Sidekiq::Job
|
|
15
|
+
# sidekiq_options unique_for: 30
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# # good - adequate TTL
|
|
19
|
+
# class MyJob
|
|
20
|
+
# include Sidekiq::Job
|
|
21
|
+
# sidekiq_options unique_for: 3600
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
class UniqueJobTooShortTTL < Base
|
|
25
|
+
MSG = 'Unique job TTL is too short (minimum: %<minimum>s seconds). ' \
|
|
26
|
+
'Consider increasing `unique_for` to cover retry period.'
|
|
27
|
+
|
|
28
|
+
MINIMUM_TTL = 60
|
|
29
|
+
|
|
30
|
+
# @!method unique_for_value(node)
|
|
31
|
+
def_node_matcher :unique_for_value, <<~PATTERN
|
|
32
|
+
(send nil? :sidekiq_options (hash <(pair (sym :unique_for) $_) ...>))
|
|
33
|
+
PATTERN
|
|
34
|
+
|
|
35
|
+
def on_send(node)
|
|
36
|
+
unique_for_value(node) do |value_node|
|
|
37
|
+
ttl = extract_seconds(value_node)
|
|
38
|
+
return unless ttl && ttl < minimum_ttl
|
|
39
|
+
|
|
40
|
+
add_offense(value_node, message: format(MSG, minimum: minimum_ttl))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
alias on_csend on_send
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def minimum_ttl
|
|
48
|
+
cop_config.fetch('MinimumTTL', MINIMUM_TTL)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def extract_seconds(node)
|
|
52
|
+
case node.type
|
|
53
|
+
when :int
|
|
54
|
+
node.value
|
|
55
|
+
when :float
|
|
56
|
+
node.value.to_i
|
|
57
|
+
when :send
|
|
58
|
+
extract_duration_seconds(node)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def extract_duration_seconds(node)
|
|
63
|
+
return unless node.receiver&.type?(:int, :float)
|
|
64
|
+
|
|
65
|
+
value = node.receiver.value.to_f
|
|
66
|
+
duration_multiplier(node.method_name, value)&.to_i
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def duration_multiplier(method_name, value)
|
|
70
|
+
case method_name
|
|
71
|
+
when :seconds, :second then value
|
|
72
|
+
when :minutes, :minute then value * 60
|
|
73
|
+
when :hours, :hour then value * 3600
|
|
74
|
+
when :days, :day then value * 86_400
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|