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.
@@ -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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Sidekiq
6
+ module ErrorGroups
7
+ class Railtie < ::Rails::Railtie
8
+ rake_tasks do
9
+ load File.expand_path("tasks.rake", __dir__)
10
+ end
11
+ end
12
+ end
13
+ 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