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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +322 -0
  4. data/config/default.yml +396 -0
  5. data/lib/rubocop/cop/sidekiq/active_record_argument.rb +77 -0
  6. data/lib/rubocop/cop/sidekiq/async_in_test.rb +42 -0
  7. data/lib/rubocop/cop/sidekiq/base.rb +14 -0
  8. data/lib/rubocop/cop/sidekiq/consistent_job_suffix.rb +44 -0
  9. data/lib/rubocop/cop/sidekiq/constant_job_class_name.rb +53 -0
  10. data/lib/rubocop/cop/sidekiq/database_connection_leak.rb +33 -0
  11. data/lib/rubocop/cop/sidekiq/date_time_argument.rb +80 -0
  12. data/lib/rubocop/cop/sidekiq/deprecated_default_worker_options.rb +44 -0
  13. data/lib/rubocop/cop/sidekiq/deprecated_delay_extension.rb +29 -0
  14. data/lib/rubocop/cop/sidekiq/deprecated_worker_module.rb +40 -0
  15. data/lib/rubocop/cop/sidekiq/enqueue_inefficiency.rb +75 -0
  16. data/lib/rubocop/cop/sidekiq/excessive_retry.rb +52 -0
  17. data/lib/rubocop/cop/sidekiq/find_each_in_job.rb +58 -0
  18. data/lib/rubocop/cop/sidekiq/huge_job_arguments.rb +73 -0
  19. data/lib/rubocop/cop/sidekiq/job_dependency.rb +30 -0
  20. data/lib/rubocop/cop/sidekiq/job_file_location.rb +52 -0
  21. data/lib/rubocop/cop/sidekiq/job_file_naming.rb +38 -0
  22. data/lib/rubocop/cop/sidekiq/job_include.rb +67 -0
  23. data/lib/rubocop/cop/sidekiq/missing_logging.rb +49 -0
  24. data/lib/rubocop/cop/sidekiq/missing_timeout.rb +65 -0
  25. data/lib/rubocop/cop/sidekiq/mixed_retry_strategies.rb +55 -0
  26. data/lib/rubocop/cop/sidekiq/mixin/argument_traversal.rb +28 -0
  27. data/lib/rubocop/cop/sidekiq/mixin/class_name_helper.rb +23 -0
  28. data/lib/rubocop/cop/sidekiq/mixin/processed_source_path.rb +19 -0
  29. data/lib/rubocop/cop/sidekiq/no_rescue_all.rb +70 -0
  30. data/lib/rubocop/cop/sidekiq/perform_inline_usage.rb +53 -0
  31. data/lib/rubocop/cop/sidekiq/perform_method_parameters.rb +53 -0
  32. data/lib/rubocop/cop/sidekiq/pii_in_arguments.rb +90 -0
  33. data/lib/rubocop/cop/sidekiq/puts_or_print_usage.rb +37 -0
  34. data/lib/rubocop/cop/sidekiq/queue_specified.rb +74 -0
  35. data/lib/rubocop/cop/sidekiq/redis_in_job.rb +32 -0
  36. data/lib/rubocop/cop/sidekiq/retry_specified.rb +81 -0
  37. data/lib/rubocop/cop/sidekiq/retry_zero.rb +48 -0
  38. data/lib/rubocop/cop/sidekiq/self_scheduling_job.rb +54 -0
  39. data/lib/rubocop/cop/sidekiq/sensitive_data_in_arguments.rb +92 -0
  40. data/lib/rubocop/cop/sidekiq/sidekiq_over_active_job.rb +30 -0
  41. data/lib/rubocop/cop/sidekiq/silent_rescue.rb +63 -0
  42. data/lib/rubocop/cop/sidekiq/sleep_in_jobs.rb +46 -0
  43. data/lib/rubocop/cop/sidekiq/symbol_argument.rb +69 -0
  44. data/lib/rubocop/cop/sidekiq/thread_in_job.rb +53 -0
  45. data/lib/rubocop/cop/sidekiq/transaction_leak.rb +72 -0
  46. data/lib/rubocop/cop/sidekiq/unknown_sidekiq_option.rb +52 -0
  47. data/lib/rubocop/cop/sidekiq_cops.rb +71 -0
  48. data/lib/rubocop/cop/sidekiq_ent/base.rb +43 -0
  49. data/lib/rubocop/cop/sidekiq_ent/encryption_with_many_arguments.rb +71 -0
  50. data/lib/rubocop/cop/sidekiq_ent/encryption_without_secret_bag.rb +83 -0
  51. data/lib/rubocop/cop/sidekiq_ent/leader_election_without_block.rb +75 -0
  52. data/lib/rubocop/cop/sidekiq_ent/limiter_not_reused.rb +79 -0
  53. data/lib/rubocop/cop/sidekiq_ent/limiter_without_lock_timeout.rb +46 -0
  54. data/lib/rubocop/cop/sidekiq_ent/limiter_without_wait_timeout.rb +49 -0
  55. data/lib/rubocop/cop/sidekiq_ent/periodic_job_invalid_cron.rb +108 -0
  56. data/lib/rubocop/cop/sidekiq_ent/periodic_job_with_arguments.rb +94 -0
  57. data/lib/rubocop/cop/sidekiq_ent/unique_job_too_short_ttl.rb +80 -0
  58. data/lib/rubocop/cop/sidekiq_ent/unique_job_without_ttl.rb +52 -0
  59. data/lib/rubocop/cop/sidekiq_ent/unique_until_mismatch.rb +59 -0
  60. data/lib/rubocop/cop/sidekiq_pro/base.rb +39 -0
  61. data/lib/rubocop/cop/sidekiq_pro/batch_callback_method.rb +66 -0
  62. data/lib/rubocop/cop/sidekiq_pro/batch_retry_in_callback.rb +54 -0
  63. data/lib/rubocop/cop/sidekiq_pro/batch_status_polling.rb +68 -0
  64. data/lib/rubocop/cop/sidekiq_pro/batch_without_callback.rb +92 -0
  65. data/lib/rubocop/cop/sidekiq_pro/empty_batch.rb +108 -0
  66. data/lib/rubocop/cop/sidekiq_pro/expiring_job_without_ttl.rb +101 -0
  67. data/lib/rubocop/cop/sidekiq_pro/large_argument_in_batch.rb +93 -0
  68. data/lib/rubocop/cop/sidekiq_pro/nested_batch_without_parent.rb +55 -0
  69. data/lib/rubocop/cop/sidekiq_pro/reliability_not_enabled.rb +67 -0
  70. data/lib/rubocop/sidekiq/config_formatter.rb +60 -0
  71. data/lib/rubocop/sidekiq/description_extractor.rb +70 -0
  72. data/lib/rubocop/sidekiq/language.rb +79 -0
  73. data/lib/rubocop/sidekiq/plugin.rb +30 -0
  74. data/lib/rubocop/sidekiq/version.rb +7 -0
  75. data/lib/rubocop/sidekiq.rb +10 -0
  76. data/lib/rubocop-sidekiq.rb +5 -0
  77. data/lib/rubocop-sidekiq_plus.rb +9 -0
  78. 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