good_job 4.19.2 → 4.21.1

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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +115 -0
  3. data/README.md +65 -8
  4. data/app/charts/good_job/latency_metric/base.rb +21 -0
  5. data/app/charts/good_job/latency_metric/execution.rb +26 -0
  6. data/app/charts/good_job/latency_metric/queue.rb +22 -0
  7. data/app/charts/good_job/latency_metric/total.rb +22 -0
  8. data/app/charts/good_job/latency_metric.rb +42 -0
  9. data/app/charts/good_job/performance_index_chart.rb +57 -22
  10. data/app/charts/good_job/performance_range.rb +380 -0
  11. data/app/charts/good_job/performance_show_chart.rb +15 -6
  12. data/app/charts/good_job/scheduled_by_queue_chart.rb +2 -2
  13. data/app/controllers/good_job/cleaner_controller.rb +1 -1
  14. data/app/controllers/good_job/performance_controller.rb +35 -16
  15. data/app/filters/good_job/base_filter.rb +12 -10
  16. data/app/filters/good_job/batches_filter.rb +12 -2
  17. data/app/frontend/good_job/application.js +2 -0
  18. data/app/frontend/good_job/modules/async_values_controller.js +7 -0
  19. data/app/frontend/good_job/modules/chart_controller.js +252 -2
  20. data/app/frontend/good_job/modules/live_poll_controller.js +16 -1
  21. data/app/frontend/good_job/modules/performance_range_controller.js +321 -0
  22. data/app/frontend/good_job/modules/timezone_name_formatter.js +13 -0
  23. data/app/frontend/good_job/style.css +102 -0
  24. data/app/helpers/good_job/application_helper.rb +34 -0
  25. data/app/models/concerns/good_job/advisory_lockable.rb +13 -0
  26. data/app/models/concerns/good_job/filterable.rb +19 -6
  27. data/app/models/good_job/batch.rb +2 -4
  28. data/app/models/good_job/concurrency_claim.rb +162 -0
  29. data/app/models/good_job/cron_entry.rb +66 -17
  30. data/app/models/good_job/job.rb +47 -7
  31. data/app/models/good_job/process.rb +11 -8
  32. data/app/models/good_job/setting.rb +16 -4
  33. data/app/views/good_job/batches/_jobs.erb +1 -1
  34. data/app/views/good_job/batches/_table.erb +1 -1
  35. data/app/views/good_job/batches/show.html.erb +1 -1
  36. data/app/views/good_job/cron_entries/index.html.erb +1 -1
  37. data/app/views/good_job/jobs/_executions.erb +1 -1
  38. data/app/views/good_job/jobs/_table.erb +1 -1
  39. data/app/views/good_job/jobs/show.html.erb +2 -2
  40. data/app/views/good_job/performance/_latency.erb +6 -0
  41. data/app/views/good_job/performance/_metric.erb +8 -0
  42. data/app/views/good_job/performance/_range.erb +91 -0
  43. data/app/views/good_job/performance/index.html.erb +52 -50
  44. data/app/views/good_job/performance/show.html.erb +6 -2
  45. data/app/views/good_job/shared/_chart_container.erb +3 -1
  46. data/config/brakeman.ignore +38 -32
  47. data/config/locales/az.yml +337 -0
  48. data/config/locales/de.yml +44 -4
  49. data/config/locales/en.yml +44 -4
  50. data/config/locales/es.yml +44 -4
  51. data/config/locales/fr.yml +44 -4
  52. data/config/locales/it.yml +44 -4
  53. data/config/locales/ja.yml +44 -4
  54. data/config/locales/ko.yml +44 -4
  55. data/config/locales/nl.yml +44 -4
  56. data/config/locales/pt-BR.yml +44 -4
  57. data/config/locales/ru.yml +60 -20
  58. data/config/locales/tr.yml +44 -4
  59. data/config/locales/uk.yml +58 -18
  60. data/config/locales/zh-CN.yml +44 -4
  61. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +37 -6
  62. data/lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb.erb +11 -6
  63. data/lib/generators/good_job/templates/update/migrations/15_add_index_good_jobs_discarded_job_class.rb.erb +13 -0
  64. data/lib/generators/good_job/templates/update/migrations/16_add_index_good_jobs_dequeue_by_queue.rb.erb +21 -0
  65. data/lib/generators/good_job/templates/update/migrations/17_add_index_good_job_executions_on_scheduled_at.rb.erb +12 -0
  66. data/lib/generators/good_job/templates/update/migrations/18_add_batch_query_indexes.rb.erb +28 -0
  67. data/lib/generators/good_job/templates/update/migrations/19_remove_redundant_good_jobs_indexes.rb.erb +55 -0
  68. data/lib/generators/good_job/templates/update/migrations/20_reorder_good_jobs_cron_index.rb.erb +51 -0
  69. data/lib/generators/good_job/templates/update/migrations/21_create_good_job_concurrency_claims.rb.erb +27 -0
  70. data/lib/good_job/active_job_extensions/concurrency.rb +128 -42
  71. data/lib/good_job/active_job_extensions/labels.rb +4 -1
  72. data/lib/good_job/adapter.rb +12 -7
  73. data/lib/good_job/bulk.rb +1 -2
  74. data/lib/good_job/capsule.rb +1 -1
  75. data/lib/good_job/capsule_tracker.rb +84 -55
  76. data/lib/good_job/cli.rb +32 -1
  77. data/lib/good_job/configuration/validator.rb +42 -0
  78. data/lib/good_job/configuration.rb +104 -3
  79. data/lib/good_job/cron_manager.rb +31 -7
  80. data/lib/good_job/lifecycle_hooks.rb +83 -0
  81. data/lib/good_job/log_subscriber.rb +78 -0
  82. data/lib/good_job/multi_scheduler.rb +2 -1
  83. data/lib/good_job/notifier/process_heartbeat.rb +30 -6
  84. data/lib/good_job/notifier.rb +5 -3
  85. data/lib/good_job/probe_server/cluster_healthcheck_middleware.rb +32 -0
  86. data/lib/good_job/probe_server/simple_handler.rb +14 -9
  87. data/lib/good_job/probe_server/webrick_handler.rb +12 -0
  88. data/lib/good_job/probe_server.rb +30 -2
  89. data/lib/good_job/shared_executor.rb +3 -1
  90. data/lib/good_job/subprocess.rb +135 -0
  91. data/lib/good_job/supervisor.rb +536 -0
  92. data/lib/good_job/systemd_service.rb +12 -1
  93. data/lib/good_job/version.rb +1 -1
  94. data/lib/good_job.rb +19 -5
  95. metadata +41 -3
  96. data/lib/good_job/overridable_connection.rb +0 -38
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob
4
+ class PerformanceRange
5
+ PARAMETER_KEYS = %w[chart_range chart_start chart_end].freeze
6
+ TIME_ZONE_PARAMETER_KEY = "chart_time_zone"
7
+ INPUT_PARAMETER_KEYS = [*PARAMETER_KEYS, TIME_ZONE_PARAMETER_KEY].freeze
8
+ DEFAULT_KEY = "24h"
9
+ MAXIMUM_TIME_SERIES_COORDINATES = 30
10
+ MAXIMUM_TIME_ZONE_LENGTH = 255
11
+ # Restrict custom bounds to portable four-digit years in the timezone used by the page.
12
+ MINIMUM_YEAR = 1000
13
+ MAXIMUM_YEAR = 9999
14
+ MINIMUM_LOCAL_VALUE = "1000-01-01T00:00:00"
15
+ MAXIMUM_LOCAL_VALUE = "9999-12-31T23:59:59"
16
+ TIMESTAMP_PATTERN = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/
17
+ LOCAL_TIMESTAMP_PATTERN = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?\z/
18
+
19
+ OPTIONS = {
20
+ "1h" => {
21
+ label: "1h",
22
+ duration: 1.hour,
23
+ interval_seconds: 5.minutes.to_i,
24
+ label_format: "%H:%M",
25
+ label_style: "time",
26
+ },
27
+ "6h" => {
28
+ label: "6h",
29
+ duration: 6.hours,
30
+ interval_seconds: 15.minutes.to_i,
31
+ label_format: "%H:%M",
32
+ label_style: "time",
33
+ },
34
+ "24h" => {
35
+ label: "24h",
36
+ duration: 24.hours,
37
+ interval_seconds: 1.hour.to_i,
38
+ label_format: "%H:%M",
39
+ label_style: "time",
40
+ },
41
+ "7d" => {
42
+ label: "7d",
43
+ duration: 24.hours * 7,
44
+ interval_seconds: 6.hours.to_i,
45
+ label_format: "%b %-d %H:%M",
46
+ label_style: "date_time",
47
+ },
48
+ }.freeze
49
+
50
+ # All candidates become integer seconds. The 30-day and 365-day scales are fixed elapsed
51
+ # durations for readable long-range charts, not calendar month or year aggregation.
52
+ SEMANTIC_INTERVALS = [
53
+ *[1, 2, 5, 10, 15, 30].map(&:seconds),
54
+ *[1, 2, 5, 10, 15, 30].map(&:minutes),
55
+ *[1, 2, 3, 6, 12].map(&:hours),
56
+ *[1, 2, 3, 5, 7, 14].map(&:days),
57
+ *[1, 2, 3, 6].map { |multiple| multiple * 30.days },
58
+ *[1, 2, 5, 10, 20, 50, 100, 200, 500].map { |multiple| multiple * 365.days },
59
+ ].map(&:to_i).uniq.sort.freeze
60
+
61
+ attr_reader :end_time, :interval_seconds, :key, :label_format, :label_style, :start_time
62
+
63
+ def initialize(params = nil, query_string: nil, **parameter_keywords)
64
+ @params = params || parameter_keywords
65
+ @repeated_parameter_keys = repeated_parameter_keys(query_string)
66
+ @local_time_zone = local_time_zone
67
+ resolve
68
+ end
69
+
70
+ def apply(relation)
71
+ relation.where(scheduled_at: start_time...end_time)
72
+ end
73
+
74
+ def canonical_parameters?(query_parameters)
75
+ query_parameters.slice(*INPUT_PARAMETER_KEYS) == to_params
76
+ end
77
+
78
+ def chart_timestamp_label(timestamp)
79
+ timestamp.in_time_zone.strftime(label_format)
80
+ end
81
+
82
+ def chart_timestamp_labels(timestamps)
83
+ labels = timestamps.map { |timestamp| chart_timestamp_label(timestamp) }
84
+
85
+ labels.each_index.group_by { |index| labels[index] }.each_value do |indices|
86
+ next unless indices.many?
87
+
88
+ offsets = indices.map { |index| timestamps[index].in_time_zone.formatted_offset }
89
+ next unless offsets.uniq.many?
90
+
91
+ indices.zip(offsets).each do |index, offset|
92
+ labels[index] = "#{labels[index]} #{offset}"
93
+ end
94
+ end
95
+
96
+ labels
97
+ end
98
+
99
+ def canonical_timestamp(timestamp)
100
+ timestamp = timestamp.in_time_zone
101
+ # ISO 8601 cannot preserve the sub-minute historical offsets present in some IANA zones.
102
+ timestamp.utc_offset.remainder(60).zero? ? timestamp.iso8601 : timestamp.utc.iso8601
103
+ end
104
+
105
+ def custom?
106
+ key.nil?
107
+ end
108
+
109
+ def custom_params
110
+ {
111
+ "chart_start" => canonical_timestamp(start_time),
112
+ "chart_end" => canonical_timestamp(end_time),
113
+ }
114
+ end
115
+
116
+ def default?
117
+ to_params.empty?
118
+ end
119
+
120
+ def end_label
121
+ range_label(end_time)
122
+ end
123
+
124
+ def end_local_value
125
+ local_value(end_time)
126
+ end
127
+
128
+ def navigation_params
129
+ return to_params unless key
130
+
131
+ # A preset is a relative definition; navigation also carries this evaluated instance.
132
+ {
133
+ "chart_range" => key,
134
+ "chart_start" => canonical_timestamp(start_time),
135
+ "chart_end" => canonical_timestamp(end_time),
136
+ }
137
+ end
138
+
139
+ def options
140
+ OPTIONS.map { |option_key, option| { key: option_key, label: option.fetch(:label) } }
141
+ end
142
+
143
+ def reload_params
144
+ return to_params if custom? || default?
145
+
146
+ { "chart_range" => key }
147
+ end
148
+
149
+ def start_end_binds
150
+ [
151
+ query_attribute("start_time", start_time, ActiveRecord::Type::DateTime.new),
152
+ query_attribute("end_time", end_time, ActiveRecord::Type::DateTime.new),
153
+ ]
154
+ end
155
+
156
+ def start_label
157
+ range_label(start_time)
158
+ end
159
+
160
+ def start_local_value
161
+ local_value(start_time)
162
+ end
163
+
164
+ # Keep this order in sync with the $1/$2/$3 placeholders in PerformanceIndexChart.
165
+ def time_series_binds
166
+ [
167
+ query_attribute("series_start_time", series_start_time, ActiveRecord::Type::DateTime.new),
168
+ query_attribute("series_end_time", series_end_time, ActiveRecord::Type::DateTime.new),
169
+ query_attribute("interval_seconds", interval_seconds, ActiveRecord::Type::Integer.new(limit: 8)),
170
+ ]
171
+ end
172
+
173
+ def time_series_bucket_sql(column_name, start_expression:, interval_expression:)
174
+ column_sql = GoodJob::Job.adapter_class.quote_column_name(column_name)
175
+
176
+ <<~SQL.squish
177
+ #{start_expression} +
178
+ FLOOR(EXTRACT(EPOCH FROM (#{column_sql} - #{start_expression})) / #{interval_expression}) *
179
+ #{interval_expression} * INTERVAL '1 second'
180
+ SQL
181
+ end
182
+
183
+ def time_series_coordinate_count
184
+ coordinate_count(interval_seconds)
185
+ end
186
+
187
+ def to_params
188
+ @canonical_params.dup
189
+ end
190
+
191
+ private
192
+
193
+ def align_time(time, interval = interval_seconds)
194
+ # JRuby can misreport rational epochs for extreme years even when integer seconds are correct.
195
+ (time - (time.to_i % interval) - time.subsec).utc
196
+ end
197
+
198
+ def aligned_epoch_seconds(time, interval)
199
+ time.to_i.div(interval) * interval
200
+ end
201
+
202
+ def coordinate_count(interval)
203
+ first_coordinate = aligned_epoch_seconds(start_time, interval)
204
+ last_coordinate = aligned_epoch_seconds(end_time - Rational(1, 1_000_000), interval)
205
+
206
+ ((last_coordinate - first_coordinate) / interval) + 1
207
+ end
208
+
209
+ def custom_times
210
+ parsed_start = parse_time("chart_start", @params[:chart_start])
211
+ parsed_end = parse_time("chart_end", @params[:chart_end])
212
+ return unless parsed_start && parsed_end && parsed_start < parsed_end
213
+
214
+ [parsed_start, parsed_end]
215
+ end
216
+
217
+ def custom_interval_seconds
218
+ # Fall back to the coarsest interval rather than raising: MINIMUM_YEAR/MAXIMUM_YEAR bound the
219
+ # widest possible range today, but nothing enforces that bound against SEMANTIC_INTERVALS.last.
220
+ SEMANTIC_INTERVALS.find do |interval|
221
+ coordinate_count(interval) <= MAXIMUM_TIME_SERIES_COORDINATES
222
+ end || SEMANTIC_INTERVALS.last
223
+ end
224
+
225
+ def custom_label_options
226
+ elapsed_seconds = end_time - start_time
227
+
228
+ if elapsed_seconds >= 365.days
229
+ { label_format: "%b %-d, %Y %H:%M", label_style: "date_time_year" }
230
+ elsif elapsed_seconds >= 24.hours
231
+ { label_format: "%b %-d %H:%M", label_style: "date_time" }
232
+ elsif interval_seconds < 1.minute
233
+ { label_format: "%H:%M:%S", label_style: "time_seconds" }
234
+ else
235
+ { label_format: "%H:%M", label_style: "time" }
236
+ end
237
+ end
238
+
239
+ def parse_time(parameter_key, value)
240
+ return if @repeated_parameter_keys.include?(parameter_key)
241
+ return unless value.is_a?(String)
242
+
243
+ timestamp = if TIMESTAMP_PATTERN.match?(value)
244
+ Time.iso8601(value)
245
+ elsif (local_match = LOCAL_TIMESTAMP_PATTERN.match(value))
246
+ parse_local_time(parameter_key, local_match)
247
+ end
248
+ return unless timestamp
249
+ return unless timestamp.to_f.finite?
250
+
251
+ timestamp = timestamp.in_time_zone.change(usec: 0)
252
+ return unless timestamp.year.between?(MINIMUM_YEAR, MAXIMUM_YEAR)
253
+
254
+ timestamp
255
+ rescue ArgumentError, RangeError
256
+ nil
257
+ end
258
+
259
+ def repeated_parameter_keys(query_string)
260
+ return [] if query_string.blank?
261
+
262
+ keys = URI.decode_www_form(query_string).filter_map do |key, _value|
263
+ key if INPUT_PARAMETER_KEYS.include?(key)
264
+ end
265
+ keys.tally.select { |_key, count| count > 1 }.keys
266
+ rescue ArgumentError
267
+ INPUT_PARAMETER_KEYS
268
+ end
269
+
270
+ def query_attribute(name, value, type)
271
+ ActiveRecord::Relation::QueryAttribute.new(name, value, type)
272
+ end
273
+
274
+ def local_value(time)
275
+ time.in_time_zone.strftime("%Y-%m-%dT%H:%M:%S")
276
+ end
277
+
278
+ def parse_local_time(parameter_key, match)
279
+ return unless @local_time_zone
280
+
281
+ components = match.captures
282
+ components[-1] ||= "0"
283
+ numeric_components = components.map { |component| Integer(component, 10) }
284
+ local_time = Time.utc(*numeric_components)
285
+ normalized_value = format("%04d-%02d-%02dT%02d:%02d:%02d", *numeric_components)
286
+ return unless local_time.strftime("%Y-%m-%dT%H:%M:%S") == normalized_value
287
+
288
+ periods = @local_time_zone.tzinfo.periods_for_local(local_time)
289
+ return if periods.empty?
290
+
291
+ # A local input cannot carry an offset. Include both repeated fall-back occurrences
292
+ # by resolving starts to the earlier instant and ends to the later instant.
293
+ instants = periods.map do |period|
294
+ (local_time - period.utc_total_offset).in_time_zone
295
+ end
296
+ parameter_key == "chart_end" ? instants.max : instants.min
297
+ end
298
+
299
+ def local_time_zone
300
+ return if @repeated_parameter_keys.include?(TIME_ZONE_PARAMETER_KEY)
301
+
302
+ value = @params[TIME_ZONE_PARAMETER_KEY] || @params[TIME_ZONE_PARAMETER_KEY.to_sym]
303
+ return Time.zone if value.nil?
304
+ return unless value.is_a?(String) && value.bytesize <= MAXIMUM_TIME_ZONE_LENGTH
305
+ return unless /\A[A-Za-z0-9._+-]+(?:\/[A-Za-z0-9._+-]+)*\z/.match?(value)
306
+
307
+ tzinfo = TZInfo::Timezone.get(value)
308
+ ActiveSupport::TimeZone.create(value, nil, tzinfo)
309
+ rescue TZInfo::InvalidTimezoneIdentifier
310
+ nil
311
+ end
312
+
313
+ def range_label(time)
314
+ local_time = time.in_time_zone
315
+ format = label_style == "date_time_year" ? "%b %-d, %Y %H:%M:%S" : "%b %-d, %H:%M:%S"
316
+ label = local_time.strftime(format)
317
+
318
+ backward_clock_transition? ? "#{label} #{local_time.formatted_offset}" : label
319
+ end
320
+
321
+ # Intentionally checks Time.zone (the server-rendered zone), not @local_time_zone: this backs
322
+ # the initial server-rendered label, before any browser-zone enhancement. The client mirrors
323
+ # this same concept for its own currently-displayed zone in
324
+ # performance_range_controller.js#crossesBackwardClockTransition — keep both in sync in spirit
325
+ # (elapsed exact time vs. elapsed civil time) but each must use its own zone, not the other's.
326
+ def backward_clock_transition?
327
+ @_backward_clock_transition ||= end_time.in_time_zone.utc_offset < start_time.in_time_zone.utc_offset
328
+ end
329
+
330
+ def resolve
331
+ range_key = preset_key
332
+
333
+ if (times = custom_times)
334
+ @start_time, @end_time = times
335
+
336
+ # Do not attach a preset identity to tampered bounds with a different elapsed duration.
337
+ if range_key && end_time - start_time == OPTIONS.fetch(range_key).fetch(:duration)
338
+ @key = range_key
339
+ @canonical_params = navigation_params
340
+ options = OPTIONS.fetch(key)
341
+ else
342
+ @key = nil
343
+ @canonical_params = custom_params
344
+ @interval_seconds = custom_interval_seconds
345
+ options = custom_label_options
346
+ end
347
+ else
348
+ @key = range_key || DEFAULT_KEY
349
+ options = OPTIONS.fetch(key)
350
+ @end_time = current_end_time
351
+ @start_time = end_time - options.fetch(:duration)
352
+ @canonical_params = range_key ? { "chart_range" => key } : {}
353
+ end
354
+
355
+ @interval_seconds ||= options.fetch(:interval_seconds)
356
+ @label_format = options.fetch(:label_format)
357
+ @label_style = options.fetch(:label_style)
358
+ end
359
+
360
+ def preset_key
361
+ return if @repeated_parameter_keys.include?("chart_range")
362
+
363
+ value = @params[:chart_range]
364
+ value if value.is_a?(String) && OPTIONS.key?(value)
365
+ end
366
+
367
+ def series_end_time
368
+ align_time(end_time - Rational(1, 1_000_000))
369
+ end
370
+
371
+ def series_start_time
372
+ align_time(start_time)
373
+ end
374
+
375
+ def current_end_time
376
+ current_time = Time.current
377
+ current_time.usec.zero? ? current_time : current_time.ceil
378
+ end
379
+ end
380
+ end
@@ -12,30 +12,33 @@ module GoodJob
12
12
  10**8 # About 3 years
13
13
  ].freeze
14
14
 
15
- def initialize(job_class)
15
+ def initialize(job_class, range = GoodJob::PerformanceRange.new, metric = GoodJob::LatencyMetric.default)
16
16
  super()
17
17
  @job_class = job_class
18
+ @range = range
19
+ @metric = metric
18
20
  end
19
21
 
20
22
  def data
21
23
  table_name = GoodJob::Execution.table_name
22
24
 
23
25
  interval_entries = BUCKET_INTERVALS.map { "interval '#{_1}'" }.join(",")
26
+ bucket_expression = "WIDTH_BUCKET(#{@metric.expression}, ARRAY[#{interval_entries}])"
24
27
  sum_query = <<~SQL.squish
25
28
  SELECT
26
- WIDTH_BUCKET(duration, ARRAY[#{interval_entries}]) as bucket_index,
27
- COUNT(WIDTH_BUCKET(duration, ARRAY[#{interval_entries}])) AS count
29
+ #{bucket_expression} as bucket_index,
30
+ COUNT(#{bucket_expression}) AS count
28
31
  FROM #{table_name} sources
29
32
  WHERE
30
- scheduled_at > $1::timestamp
33
+ scheduled_at >= $1::timestamp
31
34
  AND scheduled_at < $2::timestamp
32
35
  AND job_class = $3
33
- AND duration IS NOT NULL
36
+ AND #{@metric.presence_sql}
34
37
  GROUP BY bucket_index
35
38
  SQL
36
39
 
37
40
  binds = [
38
- *start_end_binds,
41
+ *@range.start_end_binds,
39
42
  @job_class,
40
43
  ]
41
44
  labels = BUCKET_INTERVALS.map { |interval| GoodJob::ApplicationController.helpers.format_duration(interval) }
@@ -59,6 +62,12 @@ module GoodJob
59
62
  }],
60
63
  },
61
64
  options: {
65
+ plugins: {
66
+ title: {
67
+ display: true,
68
+ text: @metric.histogram_title,
69
+ },
70
+ },
62
71
  scales: {
63
72
  y: {
64
73
  beginAtZero: true,
@@ -17,8 +17,8 @@ module GoodJob
17
17
  # offset from UTC (e.g. IST +05:30). AR serializes Time binds as UTC, so the outer
18
18
  # date_trunc operates in UTC, and this predicate must match that.
19
19
  pushdown = <<~SQL.squish
20
- "good_jobs"."scheduled_at" >= date_trunc('hour', ?::timestamp)
21
- AND "good_jobs"."scheduled_at" < date_trunc('hour', ?::timestamp) + interval '1 hour'
20
+ #{GoodJob::Job.quoted_table_name}."scheduled_at" >= date_trunc('hour', ?::timestamp)
21
+ AND #{GoodJob::Job.quoted_table_name}."scheduled_at" < date_trunc('hour', ?::timestamp) + interval '1 hour'
22
22
  SQL
23
23
 
24
24
  inner_sql = @filter.filtered_query
@@ -23,7 +23,7 @@ module GoodJob
23
23
  GoodJob::Job.discarded
24
24
  .select(<<~SQL.squish)
25
25
  job_class,
26
- count(id) AS failed,
26
+ COUNT(*) AS failed,
27
27
  COUNT(*) FILTER (WHERE "finished_at" > NOW() - INTERVAL '1 HOUR') AS last_1_hour,
28
28
  COUNT(*) FILTER (WHERE "finished_at" > NOW() - INTERVAL '3 HOURS') AS last_3_hours,
29
29
  COUNT(*) FILTER (WHERE "finished_at" > NOW() - INTERVAL '24 HOURS') AS last_24_hours,
@@ -2,27 +2,46 @@
2
2
 
3
3
  module GoodJob
4
4
  class PerformanceController < ApplicationController
5
+ before_action :set_performance_range
6
+
5
7
  def index
6
- @performances = GoodJob::Execution.group(:job_class).select("
7
- job_class,
8
- COUNT(*) AS executions_count,
9
- AVG(duration) AS avg_duration,
10
- MIN(duration) AS min_duration,
11
- MAX(duration) AS max_duration
12
- ").order(:job_class)
13
-
14
- @queue_performances = GoodJob::Execution.group(:queue_name).select("
15
- queue_name,
16
- COUNT(*) AS executions_count,
17
- AVG(duration) AS avg_duration,
18
- MIN(duration) AS min_duration,
19
- MAX(duration) AS max_duration
20
- ").order(:queue_name)
8
+ executions = @performance_range.apply(GoodJob::Execution)
9
+ aggregates = GoodJob::LatencyMetric.table_aggregates_sql
10
+
11
+ @performances = executions.group(:job_class).select("job_class, #{aggregates}").order(:job_class)
12
+ @queue_performances = executions.group(:queue_name).select("queue_name, #{aggregates}").order(:queue_name)
13
+
14
+ @chart_data = GoodJob::PerformanceIndexChart.new(@performance_range, @chart_metric).data
21
15
  end
22
16
 
23
17
  def show
24
- representative_job = GoodJob::Job.find_by!(job_class: params[:id])
18
+ representative_job = GoodJob::Job.find_by!(job_class: request.path_parameters.fetch(:id))
25
19
  @job_class = representative_job.job_class
20
+ @charts = GoodJob::LatencyMetric.options.to_h do |metric|
21
+ [metric.key, GoodJob::PerformanceShowChart.new(@job_class, @performance_range, metric).data]
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def set_performance_range
28
+ locale = request.query_parameters["locale"]
29
+ @performance_range_context = locale.is_a?(String) ? { "locale" => locale } : {}
30
+ @performance_range = GoodJob::PerformanceRange.new(params, query_string: request.query_string)
31
+ # Only the index chart is metric-switchable; show renders every metric.
32
+ @chart_metric = GoodJob::LatencyMetric.from_params(params)
33
+ @chart_metric_params = action_name == "index" ? @chart_metric.to_params : {}
34
+ return if @performance_range.canonical_parameters?(request.query_parameters)
35
+
36
+ canonical_query = @performance_range.to_params.symbolize_keys
37
+ canonical_query[:locale] = @performance_range_context["locale"]
38
+ canonical_query.merge!(@chart_metric_params.symbolize_keys)
39
+ canonical_path = if action_name == "show"
40
+ performance_path(request.path_parameters.fetch(:id), canonical_query)
41
+ else
42
+ performance_index_path(canonical_query)
43
+ end
44
+ redirect_to canonical_path
26
45
  end
27
46
  end
28
47
  end
@@ -13,15 +13,17 @@ module GoodJob
13
13
  end
14
14
 
15
15
  def records
16
- after_id = params[:after_id]
17
- after_at = params[:after_at].present? ? Time.zone.parse(params[:after_at]) : nil
18
- limit = params.fetch(:limit, DEFAULT_LIMIT)
19
-
20
- query_for_records.display_all(
21
- ordered_by: ordered_by,
22
- after_at: after_at,
23
- after_id: after_id
24
- ).limit(limit)
16
+ @_records ||= begin
17
+ after_id = params[:after_id]
18
+ after_at = params[:after_at].present? ? Time.zone.parse(params[:after_at]) : nil
19
+ limit = params.fetch(:limit, DEFAULT_LIMIT)
20
+
21
+ query_for_records.display_all(
22
+ ordered_by: ordered_by,
23
+ after_at: after_at,
24
+ after_id: after_id
25
+ ).limit(limit)
26
+ end
25
27
  end
26
28
 
27
29
  def last
@@ -76,7 +78,7 @@ module GoodJob
76
78
 
77
79
  def next_page_params
78
80
  order_column = ordered_by.first
79
- last_record = records.last
81
+ last_record = last
80
82
 
81
83
  {
82
84
  after_at: last_record&.send(order_column)&.iso8601(6),
@@ -2,16 +2,26 @@
2
2
 
3
3
  module GoodJob
4
4
  class BatchesFilter < BaseFilter
5
+ def job_count(batch)
6
+ job_counts.fetch(batch.id, 0)
7
+ end
8
+
5
9
  def filtered_query(_filtered_params = params)
6
10
  base_query
7
11
  end
8
12
 
9
13
  def query_for_records
10
- default_base_query
14
+ base_query
11
15
  end
12
16
 
13
17
  def default_base_query
14
- GoodJob::BatchRecord.includes(:jobs)
18
+ GoodJob::BatchRecord.all
19
+ end
20
+
21
+ private
22
+
23
+ def job_counts
24
+ @_job_counts ||= GoodJob::Job.where(batch_id: records.map(&:id)).group(:batch_id).count
15
25
  end
16
26
  end
17
27
  end
@@ -12,6 +12,8 @@ import AsyncValuesController from "async_values_controller";
12
12
  Stimulus.register("async-values", AsyncValuesController);
13
13
  import ChartController from "chart_controller";
14
14
  Stimulus.register("chart", ChartController);
15
+ import PerformanceRangeController from "performance_range_controller";
16
+ Stimulus.register("performance-range", PerformanceRangeController);
15
17
  import LivePollController from "live_poll_controller";
16
18
  Stimulus.register("live-poll", LivePollController);
17
19
  import CheckboxToggleController from "checkbox_toggle_controller";
@@ -7,8 +7,15 @@ export default class extends Controller {
7
7
  }
8
8
  static targets = ["value"]
9
9
 
10
+ #onLivePollRefresh = () => this.#fetch();
11
+
10
12
  connect() {
11
13
  this.#fetch();
14
+ document.addEventListener("good_job:live-poll-refreshed", this.#onLivePollRefresh);
15
+ }
16
+
17
+ disconnect() {
18
+ document.removeEventListener("good_job:live-poll-refreshed", this.#onLivePollRefresh);
12
19
  }
13
20
 
14
21
  async #fetch() {