sidekiq-error-groups 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 +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +238 -0
- data/exe/sidekiq-error-groups +6 -0
- data/lib/sidekiq/error_groups/actions.rb +124 -0
- data/lib/sidekiq/error_groups/aggregator.rb +40 -0
- data/lib/sidekiq/error_groups/cli.rb +155 -0
- data/lib/sidekiq/error_groups/compat.rb +36 -0
- data/lib/sidekiq/error_groups/config.rb +69 -0
- data/lib/sidekiq/error_groups/formatter.rb +90 -0
- data/lib/sidekiq/error_groups/group.rb +129 -0
- data/lib/sidekiq/error_groups/job.rb +137 -0
- data/lib/sidekiq/error_groups/job_query.rb +69 -0
- data/lib/sidekiq/error_groups/railtie.rb +13 -0
- data/lib/sidekiq/error_groups/scanner.rb +53 -0
- data/lib/sidekiq/error_groups/selector.rb +101 -0
- data/lib/sidekiq/error_groups/summary.rb +57 -0
- data/lib/sidekiq/error_groups/tasks.rake +17 -0
- data/lib/sidekiq/error_groups/version.rb +7 -0
- data/lib/sidekiq/error_groups/web.rb +28 -0
- data/lib/sidekiq/error_groups/web_extension.rb +178 -0
- data/lib/sidekiq/error_groups.rb +116 -0
- data/lib/sidekiq-error-groups.rb +3 -0
- data/sidekiq-error-groups.gemspec +37 -0
- data/web/locales/en.yml +35 -0
- data/web/locales/ru.yml +35 -0
- data/web/views/group.erb +114 -0
- data/web/views/index.erb +83 -0
- data/web/views/legacy/group.erb +114 -0
- data/web/views/legacy/index.erb +83 -0
- metadata +93 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module ErrorGroups
|
|
5
|
+
class Config
|
|
6
|
+
KNOWN_SETS = %i[retry dead].freeze
|
|
7
|
+
DEFAULT_SETS = KNOWN_SETS
|
|
8
|
+
|
|
9
|
+
# How many entries at most are read from each set during one aggregation.
|
|
10
|
+
# Scanning is linear in the size of the set, so this is the safety valve
|
|
11
|
+
# for installations with hundreds of thousands of dead jobs.
|
|
12
|
+
attr_accessor :max_scan
|
|
13
|
+
|
|
14
|
+
# How many matching jobs a single group action handles per pass.
|
|
15
|
+
attr_accessor :action_batch_size
|
|
16
|
+
|
|
17
|
+
# How many entries are listed in a group's breakdown column.
|
|
18
|
+
attr_accessor :top_breakdown
|
|
19
|
+
|
|
20
|
+
# Seconds to keep an aggregation result in process memory. 0 disables it.
|
|
21
|
+
attr_accessor :cache_ttl
|
|
22
|
+
|
|
23
|
+
# How many backtrace lines are shown for a group sample.
|
|
24
|
+
attr_accessor :backtrace_lines
|
|
25
|
+
|
|
26
|
+
# Jobs per page on the group detail page.
|
|
27
|
+
attr_accessor :per_page
|
|
28
|
+
|
|
29
|
+
attr_reader :sets, :group_by
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@max_scan = 10_000
|
|
33
|
+
@action_batch_size = 1_000
|
|
34
|
+
@top_breakdown = 3
|
|
35
|
+
@cache_ttl = 0
|
|
36
|
+
@backtrace_lines = 10
|
|
37
|
+
@per_page = 25
|
|
38
|
+
@sets = DEFAULT_SETS.dup
|
|
39
|
+
# Error class *and* job class: "Net::OpenTimeout in FetchInventoryJob" is
|
|
40
|
+
# a single actionable problem, "Net::OpenTimeout" alone usually is not.
|
|
41
|
+
@group_by = :error_job
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def sets=(value)
|
|
45
|
+
@sets = self.class.normalize_sets(value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def group_by=(value)
|
|
49
|
+
@group_by = self.class.normalize_group_by(value)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.normalize_sets(value)
|
|
53
|
+
sets = Array(value).map { |set| set.to_s.strip.to_sym }.uniq
|
|
54
|
+
unknown = sets - KNOWN_SETS
|
|
55
|
+
raise ArgumentError, "unknown job set(s): #{unknown.join(", ")}" if unknown.any?
|
|
56
|
+
raise ArgumentError, "at least one job set is required" if sets.empty?
|
|
57
|
+
|
|
58
|
+
sets
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.normalize_group_by(value)
|
|
62
|
+
mode = value.to_s.strip.tr("-+ ", "___").to_sym
|
|
63
|
+
return mode if Selector::MODES.include?(mode)
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "unknown grouping: #{value}, expected one of #{Selector::MODES.join(", ")}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module ErrorGroups
|
|
5
|
+
# Plain text rendering of a {Summary}, shared by the CLI and the rake task.
|
|
6
|
+
module Formatter
|
|
7
|
+
NO_ERROR_CLASS = "(no error class)"
|
|
8
|
+
|
|
9
|
+
# Columns differ per grouping: the key is always shown, and whatever is not
|
|
10
|
+
# in the key becomes the breakdown column.
|
|
11
|
+
COLUMNS = {
|
|
12
|
+
error: ["ERROR CLASS", "TOTAL", "RETRY", "DEAD", "LAST FAILURE", "JOBS"],
|
|
13
|
+
job: ["JOB", "TOTAL", "RETRY", "DEAD", "LAST FAILURE", "ERRORS"],
|
|
14
|
+
error_job: ["ERROR CLASS", "JOB", "TOTAL", "RETRY", "DEAD", "LAST FAILURE"]
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def render(summary, limit: nil, top_breakdown: nil)
|
|
20
|
+
lines = [headline(summary)]
|
|
21
|
+
return (lines << "" << "No failed jobs found.").join("\n") if summary.empty?
|
|
22
|
+
|
|
23
|
+
groups = limit ? summary.groups.first(limit) : summary.groups
|
|
24
|
+
lines << ""
|
|
25
|
+
lines.concat(table(groups, summary.group_by, top_breakdown: top_breakdown))
|
|
26
|
+
if limit && summary.groups.size > limit
|
|
27
|
+
lines << ""
|
|
28
|
+
lines << "... #{summary.groups.size - limit} more group(s), pass a bigger --limit to see them."
|
|
29
|
+
end
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def headline(summary)
|
|
34
|
+
parts = summary.set_sizes.map { |set, size| "#{set}: #{size}" }
|
|
35
|
+
line = "Failed jobs grouped by #{grouping_label(summary.group_by)} — #{summary.total} job(s) " \
|
|
36
|
+
"(#{parts.join(", ")}), #{summary.groups.size} group(s)"
|
|
37
|
+
line += "\nOnly the first #{summary.scanned} job(s) were scanned (max_scan), numbers are a lower bound." if summary.truncated?
|
|
38
|
+
line
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def grouping_label(group_by)
|
|
42
|
+
{error: "error class", job: "job class", error_job: "error class and job class"}.fetch(group_by.to_sym)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def table(groups, group_by, top_breakdown: nil)
|
|
46
|
+
headers = COLUMNS.fetch(group_by.to_sym)
|
|
47
|
+
rows = groups.map { |group| row(group, group_by, top_breakdown) }
|
|
48
|
+
widths = headers.each_with_index.map do |header, index|
|
|
49
|
+
([header] + rows.map { |r| r[index] }).map(&:length).max
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
separator = widths.map { |width| "-" * width }
|
|
53
|
+
([headers, separator] + rows).map do |row|
|
|
54
|
+
row.each_with_index.map { |cell, index| cell.ljust(widths[index]) }.join(" ").rstrip
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def row(group, group_by, top_breakdown)
|
|
59
|
+
key = case group_by.to_sym
|
|
60
|
+
when :error then [error_class_label(group.error_class)]
|
|
61
|
+
when :job then [group.job_class.to_s]
|
|
62
|
+
else [error_class_label(group.error_class), group.job_class.to_s]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
counts = [group.count.to_s, group.retry_count.to_s, group.dead_count.to_s, timestamp(group.last_failed_at)]
|
|
66
|
+
return key + counts if group_by.to_sym == :error_job
|
|
67
|
+
|
|
68
|
+
key + counts + [breakdown_label(group, top_breakdown)]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def breakdown_label(group, top_breakdown)
|
|
72
|
+
limit = top_breakdown || ErrorGroups.config.top_breakdown
|
|
73
|
+
listed = group.breakdown(limit).map { |name, count| "#{error_class_label(name)} (#{count})" }
|
|
74
|
+
rest = group.breakdown_size - listed.size
|
|
75
|
+
listed << "+#{rest} more" if rest.positive?
|
|
76
|
+
listed.join(", ")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def error_class_label(error_class)
|
|
80
|
+
error_class == Selector::UNKNOWN ? NO_ERROR_CLASS : error_class.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def timestamp(time)
|
|
84
|
+
return "-" unless time
|
|
85
|
+
|
|
86
|
+
time.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module ErrorGroups
|
|
5
|
+
# Aggregated numbers for one {Sidekiq::ErrorGroups::Selector}.
|
|
6
|
+
class Group
|
|
7
|
+
extend Forwardable
|
|
8
|
+
|
|
9
|
+
# Kept for backwards compatibility; the sentinel itself lives on Selector.
|
|
10
|
+
UNKNOWN = Selector::UNKNOWN
|
|
11
|
+
|
|
12
|
+
attr_reader :selector, :count, :set_counts, :job_classes, :error_classes,
|
|
13
|
+
:first_failed_at, :last_failed_at, :sample
|
|
14
|
+
|
|
15
|
+
def_delegators :selector, :error_class, :job_class, :mode, :matches?, :unknown_error_class?
|
|
16
|
+
|
|
17
|
+
def initialize(selector)
|
|
18
|
+
@selector = selector
|
|
19
|
+
@count = 0
|
|
20
|
+
@set_counts = {}
|
|
21
|
+
@job_classes = {}
|
|
22
|
+
@error_classes = {}
|
|
23
|
+
@first_failed_at = nil
|
|
24
|
+
@last_failed_at = nil
|
|
25
|
+
@sample = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.for(error_class: nil, job_class: nil)
|
|
29
|
+
new(Selector.new(error_class: error_class, job_class: job_class))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add(job)
|
|
33
|
+
@count += 1
|
|
34
|
+
@set_counts[job.set] = count_for(job.set) + 1
|
|
35
|
+
tally(@job_classes, job.display_class)
|
|
36
|
+
tally(@error_classes, job.error_class)
|
|
37
|
+
|
|
38
|
+
track_timestamps(job)
|
|
39
|
+
track_sample(job)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def count_for(set)
|
|
44
|
+
@set_counts.fetch(set, 0)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def retry_count
|
|
48
|
+
count_for(:retry)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def dead_count
|
|
52
|
+
count_for(:dead)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unknown?
|
|
56
|
+
unknown_error_class?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def top_job_classes(limit = ErrorGroups.config.top_breakdown)
|
|
60
|
+
top(job_classes, limit)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def top_error_classes(limit = ErrorGroups.config.top_breakdown)
|
|
64
|
+
top(error_classes, limit)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The part of the group that is *not* in its key: grouping by error class
|
|
68
|
+
# leaves the job classes worth showing, and the other way around. Grouping
|
|
69
|
+
# by both leaves nothing to break down.
|
|
70
|
+
def breakdown(limit = ErrorGroups.config.top_breakdown)
|
|
71
|
+
case mode
|
|
72
|
+
when :error then top_job_classes(limit)
|
|
73
|
+
when :job then top_error_classes(limit)
|
|
74
|
+
else []
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def breakdown_size
|
|
79
|
+
case mode
|
|
80
|
+
when :error then job_classes.size
|
|
81
|
+
when :job then error_classes.size
|
|
82
|
+
else 0
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_h
|
|
87
|
+
{
|
|
88
|
+
error_class: error_class,
|
|
89
|
+
job_class: job_class,
|
|
90
|
+
count: count,
|
|
91
|
+
sets: set_counts,
|
|
92
|
+
job_classes: job_classes,
|
|
93
|
+
error_classes: error_classes,
|
|
94
|
+
first_failed_at: first_failed_at&.iso8601,
|
|
95
|
+
last_failed_at: last_failed_at&.iso8601,
|
|
96
|
+
sample: sample&.to_h
|
|
97
|
+
}.compact
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def tally(counters, key)
|
|
103
|
+
counters[key] = counters.fetch(key, 0) + 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def top(counters, limit)
|
|
107
|
+
counters.sort_by { |name, count| [-count, name] }.first(limit)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def track_timestamps(job)
|
|
111
|
+
first = job.failed_at
|
|
112
|
+
last = job.last_failure_at
|
|
113
|
+
|
|
114
|
+
@first_failed_at = first if first && (@first_failed_at.nil? || first < @first_failed_at)
|
|
115
|
+
@last_failed_at = last if last && (@last_failed_at.nil? || last > @last_failed_at)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Keep the most recent failure as the sample: it is the one people want to
|
|
119
|
+
# look at when they open the group.
|
|
120
|
+
def track_sample(job)
|
|
121
|
+
return @sample = job if @sample.nil?
|
|
122
|
+
|
|
123
|
+
current = @sample.last_failure_at
|
|
124
|
+
candidate = job.last_failure_at
|
|
125
|
+
@sample = job if candidate && current && candidate > current
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Sidekiq
|
|
6
|
+
module ErrorGroups
|
|
7
|
+
# A failed job found in one of the job sets. Wraps a `Sidekiq::SortedEntry`
|
|
8
|
+
# and remembers which set it came from, so the UI can link back to the
|
|
9
|
+
# matching built-in page (`retries/...` or `morgue/...`).
|
|
10
|
+
#
|
|
11
|
+
# Only methods available in every supported Sidekiq (6.0+) are used, plus a
|
|
12
|
+
# few guarded with `respond_to?`.
|
|
13
|
+
class Job
|
|
14
|
+
WEB_SEGMENTS = {retry: "retries", dead: "morgue"}.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :set, :entry
|
|
17
|
+
|
|
18
|
+
def initialize(set, entry)
|
|
19
|
+
@set = set
|
|
20
|
+
@entry = entry
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def item
|
|
24
|
+
@item ||= entry.item
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def jid
|
|
28
|
+
item["jid"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def queue
|
|
32
|
+
item["queue"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def score
|
|
36
|
+
entry.score.to_f
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def at
|
|
40
|
+
Time.at(score).utc
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Unwraps ActiveJob/Delayed wrappers when Sidekiq knows how.
|
|
44
|
+
def display_class
|
|
45
|
+
@display_class ||= begin
|
|
46
|
+
entry.display_class.to_s
|
|
47
|
+
rescue StandardError
|
|
48
|
+
item["class"].to_s
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def error_class
|
|
53
|
+
value = item["error_class"]
|
|
54
|
+
value.nil? || value.to_s.empty? ? Selector::UNKNOWN : value.to_s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def error_message
|
|
58
|
+
item["error_message"].to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def retry_count
|
|
62
|
+
item["retry_count"].to_i
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def failed_at
|
|
66
|
+
@failed_at ||= parse_time(item["failed_at"]) || at
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def retried_at
|
|
70
|
+
@retried_at ||= parse_time(item["retried_at"])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The most recent failure of this job: Sidekiq only updates `retried_at`
|
|
74
|
+
# on subsequent attempts.
|
|
75
|
+
def last_failure_at
|
|
76
|
+
retried_at || failed_at
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Sidekiq 6.5+ stores backtraces zlib+base64 encoded; `error_backtrace`
|
|
80
|
+
# decodes them. Older versions store a plain array.
|
|
81
|
+
def backtrace
|
|
82
|
+
return @backtrace if defined?(@backtrace)
|
|
83
|
+
|
|
84
|
+
@backtrace = begin
|
|
85
|
+
if entry.respond_to?(:error_backtrace)
|
|
86
|
+
entry.error_backtrace
|
|
87
|
+
else
|
|
88
|
+
raw = item["error_backtrace"]
|
|
89
|
+
raw.is_a?(Array) ? raw : nil
|
|
90
|
+
end
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def web_segment
|
|
97
|
+
WEB_SEGMENTS.fetch(set, "retries")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def retry!
|
|
101
|
+
entry.retry
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def delete!
|
|
105
|
+
entry.delete
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def kill!
|
|
109
|
+
entry.kill
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def to_h
|
|
113
|
+
{
|
|
114
|
+
set: set,
|
|
115
|
+
jid: jid,
|
|
116
|
+
queue: queue,
|
|
117
|
+
job_class: display_class,
|
|
118
|
+
error_class: error_class,
|
|
119
|
+
error_message: error_message,
|
|
120
|
+
retry_count: retry_count,
|
|
121
|
+
failed_at: failed_at&.iso8601,
|
|
122
|
+
last_failure_at: last_failure_at&.iso8601
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def parse_time(value)
|
|
129
|
+
case value
|
|
130
|
+
when Numeric then Time.at(value).utc
|
|
131
|
+
when String then Time.parse(value).utc rescue nil # rubocop:disable Style/RescueModifier
|
|
132
|
+
when Time then value.utc
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module ErrorGroups
|
|
5
|
+
# Paginated list of the jobs inside one group.
|
|
6
|
+
#
|
|
7
|
+
# Jobs come back in set order (retries before dead) and, within a set, in the
|
|
8
|
+
# order Sidekiq itself uses on its Retries/Dead pages: highest score first.
|
|
9
|
+
class JobQuery
|
|
10
|
+
Page = Struct.new(:jobs, :group, :total, :page, :per_page, :scanned, :truncated, keyword_init: true) do
|
|
11
|
+
def pages
|
|
12
|
+
return 1 if per_page.zero?
|
|
13
|
+
|
|
14
|
+
[(total.to_f / per_page).ceil, 1].max
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def first_index
|
|
18
|
+
(page - 1) * per_page
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def selector
|
|
22
|
+
group.selector
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.call(error_class = nil, **kwargs)
|
|
27
|
+
new(error_class, **kwargs).call
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(error_class = nil, job_class: nil, sets: nil, page: 1, per_page: nil, max_scan: nil, sources: nil)
|
|
31
|
+
@selector = Selector.build(error_class: error_class, job_class: job_class)
|
|
32
|
+
@sets = Config.normalize_sets(sets || ErrorGroups.config.sets)
|
|
33
|
+
@per_page = (per_page || ErrorGroups.config.per_page).to_i
|
|
34
|
+
@per_page = 25 if @per_page <= 0
|
|
35
|
+
@page = page.to_i
|
|
36
|
+
@page = 1 if @page <= 0
|
|
37
|
+
@max_scan = max_scan.nil? ? ErrorGroups.config.max_scan : max_scan
|
|
38
|
+
@sources = sources
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
window = ((@page - 1) * @per_page)...(@page * @per_page)
|
|
43
|
+
matched = 0
|
|
44
|
+
jobs = []
|
|
45
|
+
# Accumulated from every matching job, not just the current page, so the
|
|
46
|
+
# detail view gets whole-group numbers out of a single scan.
|
|
47
|
+
group = Group.new(@selector)
|
|
48
|
+
|
|
49
|
+
scanned, sizes = Scanner.scan(sets: @sets, max_scan: @max_scan, sources: @sources) do |job|
|
|
50
|
+
next unless @selector.matches?(job)
|
|
51
|
+
|
|
52
|
+
jobs << job if window.cover?(matched)
|
|
53
|
+
group.add(job)
|
|
54
|
+
matched += 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Page.new(
|
|
58
|
+
jobs: jobs,
|
|
59
|
+
group: group,
|
|
60
|
+
total: matched,
|
|
61
|
+
page: @page,
|
|
62
|
+
per_page: @per_page,
|
|
63
|
+
scanned: scanned,
|
|
64
|
+
truncated: scanned < sizes.values.sum
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module ErrorGroups
|
|
5
|
+
# Walks the Sidekiq job sets and yields {Sidekiq::ErrorGroups::Job}s.
|
|
6
|
+
#
|
|
7
|
+
# Iteration goes through `Sidekiq::JobSet#each` on purpose: it paginates the
|
|
8
|
+
# sorted set internally and behaves identically on Sidekiq 6, 7 and 8.
|
|
9
|
+
# Talking to Redis directly would mean handling both redis-rb 4 (Sidekiq 6)
|
|
10
|
+
# and redis-client (Sidekiq 7+), which disagree on `ZRANGE ... WITHSCORES`.
|
|
11
|
+
module Scanner
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @return [Array(Integer, Hash)] number of entries read and size per set
|
|
15
|
+
def scan(sets:, max_scan: nil, sources: nil)
|
|
16
|
+
scanned = 0
|
|
17
|
+
sizes = {}
|
|
18
|
+
|
|
19
|
+
sets.each do |kind|
|
|
20
|
+
set = source_for(kind, sources)
|
|
21
|
+
sizes[kind] = size_of(set)
|
|
22
|
+
taken = 0
|
|
23
|
+
|
|
24
|
+
set.each do |entry|
|
|
25
|
+
break if max_scan && taken >= max_scan
|
|
26
|
+
|
|
27
|
+
taken += 1
|
|
28
|
+
scanned += 1
|
|
29
|
+
yield Job.new(kind, entry)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
[scanned, sizes]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def source_for(kind, sources = nil)
|
|
37
|
+
return sources.fetch(kind) if sources
|
|
38
|
+
|
|
39
|
+
case kind.to_sym
|
|
40
|
+
when :retry then Sidekiq::RetrySet.new
|
|
41
|
+
when :dead then Sidekiq::DeadSet.new
|
|
42
|
+
else raise ArgumentError, "unknown job set: #{kind}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def size_of(set)
|
|
47
|
+
set.size
|
|
48
|
+
rescue StandardError
|
|
49
|
+
0
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module ErrorGroups
|
|
5
|
+
# Identifies one group: an error class, a job class, or the pair of them.
|
|
6
|
+
#
|
|
7
|
+
# The same object is the grouping key during aggregation, the filter used to
|
|
8
|
+
# list or act on a group's jobs, and the pair of query parameters the Web UI
|
|
9
|
+
# puts in its links.
|
|
10
|
+
class Selector
|
|
11
|
+
# Jobs that carry no `error_class` (or, in theory, no `class`) are still
|
|
12
|
+
# worth grouping. The sentinel keeps them addressable in URLs.
|
|
13
|
+
UNKNOWN = "_unknown_"
|
|
14
|
+
|
|
15
|
+
# Which part of a job the aggregator keys on.
|
|
16
|
+
MODES = %i[error job error_job].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :error_class, :job_class
|
|
19
|
+
|
|
20
|
+
def initialize(error_class: nil, job_class: nil)
|
|
21
|
+
@error_class = normalize(error_class)
|
|
22
|
+
@job_class = normalize(job_class)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Builds the key a job belongs to under the given grouping mode.
|
|
26
|
+
def self.for(job, mode)
|
|
27
|
+
case mode
|
|
28
|
+
when :error then new(error_class: job.error_class)
|
|
29
|
+
when :job then new(job_class: job.display_class)
|
|
30
|
+
when :error_job then new(error_class: job.error_class, job_class: job.display_class)
|
|
31
|
+
else raise ArgumentError, "unknown grouping: #{mode}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Same as `new`, but refuses the empty selector, which would match every
|
|
36
|
+
# failed job — never what a caller asking for "this group" means.
|
|
37
|
+
def self.build(error_class: nil, job_class: nil)
|
|
38
|
+
selector = new(error_class: error_class, job_class: job_class)
|
|
39
|
+
raise ArgumentError, "an error class, a job class, or both are required" if selector.empty?
|
|
40
|
+
|
|
41
|
+
selector
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def mode
|
|
45
|
+
return :error_job if error_class && job_class
|
|
46
|
+
return :error if error_class
|
|
47
|
+
|
|
48
|
+
:job if job_class
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def empty?
|
|
52
|
+
error_class.nil? && job_class.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def matches?(job)
|
|
56
|
+
(error_class.nil? || job.error_class == error_class) &&
|
|
57
|
+
(job_class.nil? || job.display_class == job_class)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def unknown_error_class?
|
|
61
|
+
error_class == UNKNOWN
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def key
|
|
65
|
+
[error_class, job_class]
|
|
66
|
+
end
|
|
67
|
+
alias to_a key
|
|
68
|
+
|
|
69
|
+
def ==(other)
|
|
70
|
+
other.is_a?(Selector) && key == other.key
|
|
71
|
+
end
|
|
72
|
+
alias eql? ==
|
|
73
|
+
|
|
74
|
+
def hash
|
|
75
|
+
key.hash
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def to_h
|
|
79
|
+
{error_class: error_class, job_class: job_class}.compact
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Query string pairs for the Web UI, and `--error`/`--job` for the CLI.
|
|
83
|
+
def to_params
|
|
84
|
+
to_h.transform_keys(&:to_s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_s
|
|
88
|
+
[error_class, job_class].compact.join(" · ")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def normalize(value)
|
|
94
|
+
return nil if value.nil?
|
|
95
|
+
|
|
96
|
+
string = value.to_s
|
|
97
|
+
string.empty? ? UNKNOWN : string
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|