roundhouse_ui 0.9.1 → 0.11.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +620 -46
  3. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +246 -12
  4. data/app/controllers/roundhouse_ui/application_controller.rb +74 -0
  5. data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
  6. data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
  7. data/app/controllers/roundhouse_ui/dashboard_controller.rb +27 -3
  8. data/app/controllers/roundhouse_ui/dead_controller.rb +27 -10
  9. data/app/controllers/roundhouse_ui/errors_controller.rb +60 -1
  10. data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
  11. data/app/controllers/roundhouse_ui/queues_controller.rb +57 -6
  12. data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
  13. data/app/controllers/roundhouse_ui/retries_controller.rb +34 -10
  14. data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -7
  15. data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
  16. data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
  17. data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
  18. data/app/helpers/roundhouse_ui/application_helper.rb +293 -0
  19. data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
  20. data/app/helpers/roundhouse_ui/observability_helper.rb +121 -12
  21. data/app/helpers/roundhouse_ui/tags_helper.rb +128 -0
  22. data/app/views/layouts/roundhouse_ui/application.html.erb +1277 -84
  23. data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
  24. data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
  25. data/app/views/roundhouse_ui/dead/index.html.erb +54 -32
  26. data/app/views/roundhouse_ui/errors/index.html.erb +51 -11
  27. data/app/views/roundhouse_ui/jobs/show.html.erb +8 -3
  28. data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
  29. data/app/views/roundhouse_ui/queues/index.html.erb +54 -10
  30. data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
  31. data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
  32. data/app/views/roundhouse_ui/retries/index.html.erb +30 -17
  33. data/app/views/roundhouse_ui/scheduled/index.html.erb +17 -11
  34. data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
  35. data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
  36. data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
  37. data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
  38. data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
  39. data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +28 -0
  40. data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
  41. data/config/routes.rb +9 -0
  42. data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
  43. data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
  44. data/lib/roundhouse_ui/demo.rb +130 -0
  45. data/lib/roundhouse_ui/error_groups.rb +25 -4
  46. data/lib/roundhouse_ui/filter_query.rb +504 -0
  47. data/lib/roundhouse_ui/health.rb +43 -4
  48. data/lib/roundhouse_ui/history.rb +54 -0
  49. data/lib/roundhouse_ui/icons.rb +104 -0
  50. data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
  51. data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
  52. data/lib/roundhouse_ui/observability.rb +55 -7
  53. data/lib/roundhouse_ui/pause.rb +6 -11
  54. data/lib/roundhouse_ui/queue_summary.rb +11 -0
  55. data/lib/roundhouse_ui/recurring.rb +137 -0
  56. data/lib/roundhouse_ui/runbooks.rb +75 -0
  57. data/lib/roundhouse_ui/snapshots.rb +13 -2
  58. data/lib/roundhouse_ui/tags.rb +112 -0
  59. data/lib/roundhouse_ui/theme.rb +298 -0
  60. data/lib/roundhouse_ui/version.rb +1 -1
  61. data/lib/roundhouse_ui.rb +235 -5
  62. data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
  63. metadata +32 -7
@@ -6,10 +6,9 @@ module RoundhouseUi
6
6
  # backend (Solid Queue) can supply duck-typed equivalents without touching
7
7
  # controllers or views.
8
8
  #
9
- # Deliberately thin for now: each method returns the same Sidekiq object the
10
- # controllers used before, so this refactor changes no behavior. The contract
11
- # (what a "set" / "entry" must respond to) will be firmed up as the Solid
12
- # Queue backend is built against it.
9
+ # Mostly thin — most methods return the Sidekiq object the controllers used before
10
+ # the port was extracted. The exceptions are the batched reads (queue_summaries)
11
+ # and the capability list, which the Solid Queue backend firmed up.
13
12
  class Sidekiq
14
13
  def name = "Sidekiq"
15
14
 
@@ -18,7 +17,14 @@ module RoundhouseUi
18
17
  # fetcher), so :native_pause is withheld and the "not enforced" warning
19
18
  # applies — but Sidekiq Pro ships its own enforced pause, so there it is
20
19
  # advertised and the warning drops away.
21
- CAPABILITIES = %i[retries dead scheduled busy workers redis capsules].freeze
20
+ CAPABILITIES = %i[retries dead scheduled busy workers redis capsules cancel history
21
+ retry_job enqueue enqueue_now snapshots].freeze
22
+
23
+ # Process-local, deliberately: this is a cheap staleness gate, not shared
24
+ # state. Every process reaches its own conclusion within CONCURRENCY_TTL.
25
+ class << self
26
+ attr_accessor :concurrency_cache
27
+ end
22
28
 
23
29
  def supports?(capability)
24
30
  return RoundhouseUi::Pause.native? if capability == :native_pause
@@ -29,6 +35,80 @@ module RoundhouseUi
29
35
  def stats = ::Sidekiq::Stats.new
30
36
  def queues = ::Sidekiq::Queue.all
31
37
  def queue(name) = ::Sidekiq::Queue.new(name)
38
+
39
+ # The jobs waiting on one queue. Sidekiq::Queue is Enumerable and pages
40
+ # through Redis in chunks, so callers that stop early (browse fills one
41
+ # page and breaks) never load a large queue into memory.
42
+ def queue_jobs(name) = ::Sidekiq::Queue.new(name)
43
+
44
+ # Sidekiq keeps a per-day counter whether anyone reads it or not, so this
45
+ # is free history — no storage of ours, no migration, works everywhere.
46
+ # Oldest first: a chart should not have to reverse it.
47
+ def history(days)
48
+ h = ::Sidekiq::Stats::History.new(days)
49
+ processed = h.processed
50
+ failed = h.failed
51
+ processed.keys.sort.map do |date|
52
+ RoundhouseUi::Day.new(date: date, processed: processed[date].to_i, failed: failed[date].to_i)
53
+ end
54
+ end
55
+
56
+ # Total worker threads across every reporting process, for the capacity
57
+ # figure (#36). Deliberately not Stats#workers_size, which counts threads
58
+ # *busy this instant* — that goes to zero on an idle fleet, and dividing a
59
+ # required rate by it would claim the fleet has infinite headroom.
60
+ #
61
+ # Two things this call has to get right, because it runs on the poll
62
+ # endpoint that every open tab hits every few seconds:
63
+ #
64
+ # * `ProcessSet.new(false)` skips Sidekiq's dead-process cleanup, which
65
+ # issues a SET and can issue an SREM. Roundhouse is an observer; a
66
+ # read endpoint must not write to Redis, least of all on a read-only
67
+ # install. Sidekiq's own workers prune the set.
68
+ # * The result is cached briefly. Thread counts change on deploy or
69
+ # scale, not between polls, so re-reading the process set on every
70
+ # poll spends round-trips on an answer that has not moved.
71
+ CONCURRENCY_TTL = 15.0 # seconds
72
+
73
+ def concurrency
74
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
75
+ cached = self.class.concurrency_cache
76
+ return cached[:value] if cached && now - cached[:at] < CONCURRENCY_TTL
77
+
78
+ value = ::Sidekiq::ProcessSet.new(false).sum { |p| p["concurrency"].to_i }
79
+ self.class.concurrency_cache = { value: value, at: now }
80
+ value
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ # Depth and latency for every queue in two Redis round-trips, regardless
86
+ # of how many queues there are.
87
+ #
88
+ # Sidekiq::Queue#size and #latency each issue their own command, so the
89
+ # obvious `queues.map { ... }` costs one SSCAN plus a LLEN and an LRANGE
90
+ # per queue — on an app with sixty queues, a couple of hundred round-trips
91
+ # to render one page. Sidekiq's own Queues#lengths pipelines the LLENs for
92
+ # the same reason; this pipelines the LRANGEs alongside them.
93
+ #
94
+ # Queues with no ready work are kept, at size 0: an operator has to be able
95
+ # to pause or snapshot a queue that happens to be empty right now.
96
+ def queue_summaries
97
+ # Sidekiq 8 ships an equivalent (Stats#queue_summaries) but 6.5 and 7 do
98
+ # not, and its Data objects carry a `paused` field ours cannot. Branching
99
+ # on it would hand callers a different shape depending on which Sidekiq
100
+ # the host runs, so this stays one implementation for every supported
101
+ # version — the reason the gem is portable in the first place.
102
+ names = ::Sidekiq.redis { |c| c.call("SMEMBERS", "queues") }.sort
103
+ return [] if names.empty?
104
+
105
+ sizes, oldest = batch_read(names).each_slice(names.size).to_a
106
+ names.each_with_index.map do |name, i|
107
+ RoundhouseUi::QueueSummary.new(name: name, size: sizes[i].to_i,
108
+ latency: latency_from(oldest[i]))
109
+ end
110
+ end
111
+
32
112
  def retry_set = ::Sidekiq::RetrySet.new
33
113
  def dead_set = ::Sidekiq::DeadSet.new
34
114
  def scheduled_set = ::Sidekiq::ScheduledSet.new
@@ -60,6 +140,45 @@ module RoundhouseUi
60
140
  end
61
141
  end
62
142
 
143
+ private
144
+
145
+ # Everything goes through conn.call, whose splat signature is identical on
146
+ # redis-rb (Sidekiq 6.x) and redis-client (7+) — the same reason the rest of
147
+ # this gem avoids the high-level command methods. Pipelining is
148
+ # feature-detected the way DurationCollector does it.
149
+ def batch_read(names)
150
+ commands = names.map { |n| [ "LLEN", "queue:#{n}" ] } +
151
+ names.map { |n| [ "LRANGE", "queue:#{n}", -1, -1 ] }
152
+ ::Sidekiq.redis do |conn|
153
+ if conn.respond_to?(:pipelined)
154
+ conn.pipelined { |pipe| commands.map { |c| pipe.call(*c) } }
155
+ else
156
+ commands.map { |c| conn.call(*c) }
157
+ end
158
+ end
159
+ end
160
+
161
+ # The tail entry of a list is its oldest.
162
+ #
163
+ # Two timestamp formats exist and the difference is three orders of
164
+ # magnitude: Sidekiq 8 writes enqueued_at as integer epoch milliseconds,
165
+ # 6.5 and 7 write float epoch seconds. Treating one as the other does not
166
+ # produce a slightly wrong latency, it produces nonsense. This mirrors the
167
+ # dispatch in Sidekiq 8's own ApiUtils#calculate_latency.
168
+ #
169
+ # A payload that will not parse must not take down the page.
170
+ def latency_from(entry)
171
+ payload = entry.is_a?(Array) ? entry.first : entry
172
+ return 0.0 unless payload
173
+
174
+ at = RoundhouseUi.enqueued_at(::Sidekiq.load_json(payload))
175
+ at ? (Time.now - at).to_f : 0.0
176
+ rescue StandardError
177
+ 0.0
178
+ end
179
+
180
+ public
181
+
63
182
  # --- pause (via the opt-in Roundhouse fetcher; not native) ---
64
183
  def paused_queues = RoundhouseUi::Pause.paused_set
65
184
  def pause(name) = RoundhouseUi::Pause.pause!(name)
@@ -14,7 +14,10 @@ module RoundhouseUi
14
14
 
15
15
  # No retries/redis/capsules/workers (deferred); pause is native (no fetcher
16
16
  # warning). dead/scheduled/busy are supported.
17
- CAPABILITIES = %i[dead scheduled busy native_pause].freeze
17
+ # retry_job: SolidQueue::FailedExecution#retry exists, so dead-set retry works.
18
+ # Absent on purpose: :enqueue and :enqueue_now (push and add_to_queue are not
19
+ # implemented), :snapshots and :redis (both need Redis).
20
+ CAPABILITIES = %i[dead scheduled busy native_pause retry_job].freeze
18
21
  def supports?(capability) = CAPABILITIES.include?(capability)
19
22
 
20
23
  def stats
@@ -32,6 +35,44 @@ module RoundhouseUi
32
35
  def queues = ::SolidQueue::Queue.all
33
36
  def queue(name) = ::SolidQueue::Queue.new(name)
34
37
 
38
+ # The jobs waiting on one queue, in the same JobSet shape the other sets
39
+ # use — so the queue detail page reuses the shared browse/search path
40
+ # rather than growing a second one.
41
+ # Solid Queue's processes table records threads per worker the same way.
42
+ def concurrency
43
+ sq(:Process).where(kind: "Worker").sum { |p| p.metadata&.dig("thread_pool_size").to_i }
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ def queue_jobs(name) = JobSet.new(sq(:ReadyExecution).queued_as(name))
49
+
50
+ # Depth and latency for every queue in two queries, not two per queue.
51
+ #
52
+ # SolidQueue::Queue#size and #latency each run their own COUNT and MIN, so
53
+ # rendering the Queues page over sixty queues meant well over a hundred
54
+ # round-trips to the queue database. One GROUP BY answers both for every
55
+ # queue at once.
56
+ #
57
+ # Queue.all is still consulted, because it lists queues by distinct job
58
+ # name — including ones with nothing ready right now — and an operator has
59
+ # to be able to pause or clear a queue that happens to be empty.
60
+ def queue_summaries
61
+ counts = sq(:ReadyExecution).group(:queue_name)
62
+ .pluck(Arel.sql("queue_name, COUNT(*), MIN(created_at)"))
63
+ .to_h { |name, size, oldest| [ name, [ size, oldest ] ] }
64
+
65
+ names = (::SolidQueue::Queue.all.map(&:name) | counts.keys).sort
66
+ now = Time.current
67
+ names.map do |name|
68
+ size, oldest = counts[name]
69
+ RoundhouseUi::QueueSummary.new(
70
+ name: name, size: size.to_i,
71
+ latency: oldest ? (now - oldest).to_i : 0
72
+ )
73
+ end
74
+ end
75
+
35
76
  # No retry set in Solid Queue — surfaced via supports?(:retries) => false.
36
77
  def retry_set = EMPTY_SET
37
78
 
@@ -105,8 +146,18 @@ module RoundhouseUi
105
146
  }
106
147
  end
107
148
 
108
- def retry = @execution.retry if @execution.respond_to?(:retry)
109
- def delete = @execution.destroy
149
+ # Guarded at CALL time. As a trailing `if` on an endless def this was evaluated
150
+ # when the class body loaded, against a nil class-level ivar — so the method
151
+ # was never defined and every Retry raised NoMethodError.
152
+ def retry
153
+ raise NotImplementedError, "this entry cannot be retried" unless @execution.respond_to?(:retry)
154
+
155
+ @execution.retry
156
+ end
157
+ # discard removes the job row along with the execution and takes the
158
+ # row lock while it does; destroy drops only the execution, leaving an
159
+ # orphaned solid_queue_jobs row that nothing will ever pick up again.
160
+ def delete = @execution.respond_to?(:discard) ? @execution.discard : @execution.destroy
110
161
 
111
162
  private
112
163
 
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+
6
+ module RoundhouseUi
7
+ # Workers that do real work and really fail, so a console has something to show.
8
+ #
9
+ # NOT loaded by `require "roundhouse_ui"`. Ask for it explicitly, in an
10
+ # initializer you would not ship:
11
+ #
12
+ # require "roundhouse_ui/demo" if Rails.env.development?
13
+ #
14
+ # Then drive it with `rake roundhouse_ui:demo:load[10]`.
15
+ #
16
+ # Each class refuses to run outside development or test. A worker that sleeps
17
+ # for twelve seconds and then raises is a fine thing to have in a demo and a
18
+ # bad thing to have reachable in production, and "nobody will enqueue it" is
19
+ # not a control.
20
+ module Demo
21
+ class Base
22
+ include Sidekiq::Job
23
+
24
+ # Three attempts, so a failing job reaches the dead set inside a short
25
+ # demo instead of backing off for hours.
26
+ sidekiq_options retry: 3
27
+
28
+ # Subclasses override. Milliseconds of work, and the chance of failing.
29
+ DURATION = (100..400)
30
+ FAILURE_RATE = 0.0
31
+ ERRORS = [ [ RuntimeError, "something went wrong" ] ].freeze
32
+
33
+ def perform(payload = {})
34
+ unless %w[development test].include?(ENV["RAILS_ENV"] || Rails.env.to_s)
35
+ raise "RoundhouseUi::Demo workers do not run outside development"
36
+ end
37
+
38
+ work_for(self.class::DURATION)
39
+ maybe_fail(payload)
40
+ end
41
+
42
+ private
43
+
44
+ # Real elapsed time, and real CPU — a job that only sleeps shows up on the
45
+ # Busy page but never in a flame graph, and the point is to look like work.
46
+ def work_for(range)
47
+ budget = rand(range) / 1000.0
48
+ deadline = now + budget
49
+ digest = SecureRandom.hex(8)
50
+ while now < deadline
51
+ 200.times { digest = Digest::SHA256.hexdigest(digest) }
52
+ sleep 0.01
53
+ end
54
+ digest
55
+ end
56
+
57
+ def maybe_fail(payload)
58
+ return if rand >= self.class::FAILURE_RATE
59
+
60
+ klass, message = self.class::ERRORS.sample
61
+ raise klass, "#{message} (account #{payload['account_id'] || '—'})"
62
+ end
63
+
64
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ end
66
+
67
+ # A payment path: quick, and fails often enough to group.
68
+ class CheckoutWorker < Base
69
+ OWNER = :core
70
+ QUEUE = "stripe"
71
+ DURATION = (200..1_200)
72
+ FAILURE_RATE = 0.18
73
+ ERRORS = [
74
+ [ Timeout::Error, "Stripe API timed out after 8s" ],
75
+ [ ArgumentError, "no such price" ]
76
+ ].freeze
77
+ end
78
+
79
+ # Mail: fast and mostly reliable, so the throughput line has volume.
80
+ class DigestMailerWorker < Base
81
+ OWNER = :growth
82
+ QUEUE = "invite_mailer"
83
+ DURATION = (60..300)
84
+ FAILURE_RATE = 0.04
85
+ ERRORS = [ [ IOError, "421 4.7.0 Try again later" ] ].freeze
86
+ end
87
+
88
+ # Search reindex: slow enough to sit on the Busy page while you look at it.
89
+ class ReindexWorker < Base
90
+ OWNER = :platform
91
+ QUEUE = "default"
92
+ DURATION = (1_500..5_000)
93
+ FAILURE_RATE = 0.09
94
+ ERRORS = [ [ IOError, "connection reset by peer" ] ].freeze
95
+ end
96
+
97
+ # The long one: guaranteed to be mid-flight whenever you open Busy.
98
+ class TranscodeWorker < Base
99
+ OWNER = :platform
100
+ QUEUE = "uploaded_files"
101
+ DURATION = (6_000..20_000)
102
+ FAILURE_RATE = 0.07
103
+ ERRORS = [ [ RuntimeError, "ffmpeg exited with status 1" ] ].freeze
104
+ end
105
+
106
+ # The flaky one: the group that dominates the Errors page.
107
+ class EmbeddingWorker < Base
108
+ OWNER = :ai
109
+ QUEUE = "ai"
110
+ DURATION = (400..2_500)
111
+ FAILURE_RATE = 0.24
112
+ ERRORS = [
113
+ [ Timeout::Error, "embedding endpoint timed out" ],
114
+ [ RuntimeError, "429 rate limited" ],
115
+ [ KeyError, "key not found: :embedding" ]
116
+ ].freeze
117
+ end
118
+
119
+ class GamificationWorker < Base
120
+ OWNER = :training
121
+ QUEUE = "gamification"
122
+ DURATION = (100..600)
123
+ FAILURE_RATE = 0.06
124
+ ERRORS = [ [ RuntimeError, "badge already awarded" ] ].freeze
125
+ end
126
+
127
+ ALL = [ CheckoutWorker, DigestMailerWorker, ReindexWorker,
128
+ TranscodeWorker, EmbeddingWorker, GamificationWorker ].freeze
129
+ end
130
+ end
@@ -14,6 +14,7 @@ module RoundhouseUi
14
14
  def initialize(query: nil, limit: DEFAULT_SCAN_LIMIT)
15
15
  @query = query.to_s.strip
16
16
  @limit = limit
17
+ @tag_cache = {}
17
18
  end
18
19
 
19
20
  def call
@@ -34,12 +35,22 @@ module RoundhouseUi
34
35
  end
35
36
 
36
37
  list = groups.values.sort_by { |g| -g[:count] }
37
- list = list.select { |g| "#{g[:klass]} #{g[:error]}".downcase.include?(@query.downcase) } if @query.present?
38
+ list = list.select { |g| matches?(g) } if @query.present?
38
39
  Result.new(groups: list, scanned: scanned, truncated: truncated)
39
40
  end
40
41
 
41
42
  private
42
43
 
44
+ # Tag values are part of the haystack here for the same reason they are on
45
+ # the job sets: typing a squad name should find that squad's failures. A
46
+ # group's entries all share a class, so the tag is constant for the group
47
+ # and resolves once per group rather than per scanned entry.
48
+ def matches?(group)
49
+ haystack = [ group[:klass], group[:error] ]
50
+ haystack.concat(Tags.for(klass: group[:klass], item: {}, cache: @tag_cache).values) if RoundhouseUi.job_tags
51
+ haystack.join(" ").downcase.include?(@query.downcase)
52
+ end
53
+
43
54
  # Sidekiq's native sets, plus the sidekiq-failures `failed` set when opted in
44
55
  # and loaded. Its FailureSet is a Sidekiq::JobSet, so it iterates like the rest.
45
56
  def sources
@@ -51,9 +62,19 @@ module RoundhouseUi
51
62
  end
52
63
 
53
64
  def record(groups, source, entry)
54
- error = entry.item["error_class"] || "UnknownError"
55
- group = (groups["#{entry.klass}|#{error}"] ||= {
56
- klass: entry.klass, error: error, count: 0, last_at: nil, queues: [], sources: []
65
+ # The bucket key and the stored label must unwrap together — split them and
66
+ # rows merge under one name while a different one renders. Everything
67
+ # downstream reads group[:klass]: the label, the search haystack, the tag
68
+ # lookup, the Datadog link, and the dashboard's errors_path(q:) href.
69
+ #
70
+ # item is bound once because Solid Queue's Entry#item builds a fresh Hash
71
+ # and touches the execution on every call, and this runs up to
72
+ # DEFAULT_SCAN_LIMIT times per render.
73
+ item = entry.item
74
+ klass = RoundhouseUi.unwrapped_class(entry.klass, item)
75
+ error = item["error_class"] || "UnknownError"
76
+ group = (groups["#{klass}|#{error}"] ||= {
77
+ klass: klass, error: error, count: 0, last_at: nil, queues: [], sources: []
57
78
  })
58
79
  group[:count] += 1
59
80
  group[:queues] |= [ entry.queue ]