good_job 4.19.2 → 4.19.3

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +1 -1
  4. data/app/charts/good_job/performance_index_chart.rb +50 -17
  5. data/app/charts/good_job/performance_range.rb +380 -0
  6. data/app/charts/good_job/performance_show_chart.rb +4 -3
  7. data/app/charts/good_job/scheduled_by_queue_chart.rb +2 -2
  8. data/app/controllers/good_job/cleaner_controller.rb +1 -1
  9. data/app/controllers/good_job/performance_controller.rb +28 -3
  10. data/app/frontend/good_job/application.js +2 -0
  11. data/app/frontend/good_job/modules/chart_controller.js +252 -2
  12. data/app/frontend/good_job/modules/performance_range_controller.js +321 -0
  13. data/app/frontend/good_job/modules/timezone_name_formatter.js +13 -0
  14. data/app/frontend/good_job/style.css +97 -0
  15. data/app/helpers/good_job/application_helper.rb +16 -0
  16. data/app/models/good_job/cron_entry.rb +34 -4
  17. data/app/models/good_job/setting.rb +16 -4
  18. data/app/views/good_job/performance/_range.erb +91 -0
  19. data/app/views/good_job/performance/index.html.erb +17 -5
  20. data/app/views/good_job/performance/show.html.erb +4 -2
  21. data/config/locales/az.yml +321 -0
  22. data/config/locales/de.yml +24 -0
  23. data/config/locales/en.yml +24 -0
  24. data/config/locales/es.yml +24 -0
  25. data/config/locales/fr.yml +24 -0
  26. data/config/locales/it.yml +24 -0
  27. data/config/locales/ja.yml +24 -0
  28. data/config/locales/ko.yml +24 -0
  29. data/config/locales/nl.yml +24 -0
  30. data/config/locales/pt-BR.yml +24 -0
  31. data/config/locales/ru.yml +24 -0
  32. data/config/locales/tr.yml +24 -0
  33. data/config/locales/uk.yml +24 -0
  34. data/config/locales/zh-CN.yml +24 -0
  35. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +3 -0
  36. data/lib/generators/good_job/templates/update/migrations/15_add_index_good_jobs_discarded_job_class.rb.erb +13 -0
  37. data/lib/good_job/adapter.rb +12 -7
  38. data/lib/good_job/configuration/validator.rb +42 -0
  39. data/lib/good_job/configuration.rb +12 -0
  40. data/lib/good_job/probe_server.rb +12 -2
  41. data/lib/good_job/version.rb +1 -1
  42. data/lib/good_job.rb +3 -3
  43. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ad85ff0a870b41065fedac7c97b85f06606227b8a508851d775fabf90d2cc44
4
- data.tar.gz: b4060b5afd727017bc337833d5c3d152f8f08465077edc42cbba9f929da72452
3
+ metadata.gz: b4d6ba03812a521f55ab70bb95caafb96aba83c049036e9f8cc5d7489f3f8dcc
4
+ data.tar.gz: 3898461e6eccf9e8c6106b69a41123b82ff2e5a4697205464c60336304b4a5ef
5
5
  SHA512:
6
- metadata.gz: a6f6a889caf1e0566fb98b18603fabcfde6f2499cffc96d9ddec3eb3fd15e1049d32d2e16a0c42284410d43bd6bb849b57f5e54ac3dea989a1e9eba929a42034
7
- data.tar.gz: f671e1ee8a746c4f771339d4f5afdd2da089e16934129462890645b44ebd013f13568feb1e278df06fb84ce49a1020e43f0813c481da60e16008d7b7944c05cf
6
+ metadata.gz: c924ff049f2b6153f1e7a98f7ff5eb35f68dfecd38079e99d84f535da56628eef67ae453a2b6255759d037f563249f02e4c442eae4a5196fdbf7ca2655f62e39
7
+ data.tar.gz: a0724d9b3ded29985d5e16958d38ba3cddc90f66d800a0c13e68581f455a338263987ab167e2a8010289786d9917585f1b1627c5657696e84cfd8b90e96f3d9b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [v4.19.3](https://github.com/bensheldon/good_job/tree/v4.19.3) (2026-09-21)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.2...v4.19.3)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - Add GoodJob::Configuration\#valid? to validate Cron configuration [\#1796](https://github.com/bensheldon/good_job/pull/1796) ([bensheldon](https://github.com/bensheldon))
10
+ - Add time range controls to dashboard time-series charts [\#1782](https://github.com/bensheldon/good_job/pull/1782) ([TikiTDO](https://github.com/TikiTDO))
11
+ - Allow Continuation jobs to be paused [\#1778](https://github.com/bensheldon/good_job/pull/1778) ([bdewater-thatch](https://github.com/bdewater-thatch))
12
+
13
+ **Fixed bugs:**
14
+
15
+ - Use GoodJob::Job.table\_name instead of hardcoded `good_jobs` [\#1815](https://github.com/bensheldon/good_job/pull/1815) ([gap777](https://github.com/gap777))
16
+
17
+ **Closed issues:**
18
+
19
+ - Hardcoded table names interfere with applications using custom table names. [\#1814](https://github.com/bensheldon/good_job/issues/1814)
20
+
21
+ **Merged pull requests:**
22
+
23
+ - Fix alphabetization of demo schema.rb columns [\#1818](https://github.com/bensheldon/good_job/pull/1818) ([bensheldon](https://github.com/bensheldon))
24
+ - Pin json \< 3 as a temporary workaround [\#1817](https://github.com/bensheldon/good_job/pull/1817) ([gap777](https://github.com/gap777))
25
+ - Add Azerbaijani locale [\#1813](https://github.com/bensheldon/good_job/pull/1813) ([jamalkamaladdin](https://github.com/jamalkamaladdin))
26
+ - Bump github/codeql-action from 4.37.3 to 4.37.9 [\#1809](https://github.com/bensheldon/good_job/pull/1809) ([dependabot[bot]](https://github.com/apps/dependabot))
27
+ - Deprecate the fallback when `probe_handler` can't be used [\#1808](https://github.com/bensheldon/good_job/pull/1808) ([shouichi](https://github.com/shouichi))
28
+ - Bump github/codeql-action from 4 to 4.37.3 [\#1802](https://github.com/bensheldon/good_job/pull/1802) ([dependabot[bot]](https://github.com/apps/dependabot))
29
+ - Add index for discarded jobs with job\_class [\#1795](https://github.com/bensheldon/good_job/pull/1795) ([clinejj](https://github.com/clinejj))
30
+
3
31
  ## [v4.19.2](https://github.com/bensheldon/good_job/tree/v4.19.2) (2026-07-20)
4
32
 
5
33
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.1...v4.19.2)
data/README.md CHANGED
@@ -1716,7 +1716,7 @@ Note that GoodJob doesn't include WEBrick as a dependency, so you'll need to add
1716
1716
  gem 'webrick'
1717
1717
  ```
1718
1718
 
1719
- If WEBrick is configured to be used, but the dependency is not found, GoodJob will log a warning and fallback to the default probe server.
1719
+ If WEBrick is configured to be used, but the dependency is not found, GoodJob will log a warning and fallback to the default probe server. This behavior is deprecated and will raise in the next release. An unsupported `probe_handler` value behaves the same way.
1720
1720
 
1721
1721
  ### Pausing Jobs
1722
1722
 
@@ -2,39 +2,64 @@
2
2
 
3
3
  module GoodJob
4
4
  class PerformanceIndexChart < BaseChart
5
+ def initialize(range = GoodJob::PerformanceRange.new)
6
+ super()
7
+ @range = range
8
+ end
9
+
5
10
  def data
6
- table_name = GoodJob::Execution.table_name
11
+ binds = @range.time_series_binds
12
+ bucket_sql = @range.time_series_bucket_sql(
13
+ "scheduled_at",
14
+ start_expression: "range_parameters.series_start_time",
15
+ interval_expression: "range_parameters.interval_seconds"
16
+ )
17
+ inner_sql = @range.apply(GoodJob::Execution).select(:job_class, :scheduled_at, :duration).to_sql
7
18
 
19
+ # Keep each bind placeholder single-use because JDBC binds every converted occurrence.
8
20
  sum_query = <<~SQL.squish
9
- SELECT *
10
- FROM generate_series(
11
- date_trunc('hour', $1::timestamp),
12
- date_trunc('hour', $2::timestamp),
13
- '1 hour'
14
- ) timestamp
15
- LEFT JOIN (
21
+ WITH range_parameters AS (
16
22
  SELECT
17
- date_trunc('hour', scheduled_at) AS scheduled_at,
18
- job_class,
19
- SUM(duration) AS sum
20
- FROM #{table_name} sources
21
- GROUP BY date_trunc('hour', scheduled_at), job_class
22
- ) sources ON sources.scheduled_at = timestamp
23
+ $1::timestamp AS series_start_time,
24
+ $2::timestamp AS series_end_time,
25
+ $3::bigint AS interval_seconds
26
+ ), timestamps AS (
27
+ SELECT generate_series(
28
+ series_start_time,
29
+ series_end_time,
30
+ interval_seconds * INTERVAL '1 second'
31
+ ) AS timestamp
32
+ FROM range_parameters
33
+ ), sources AS (
34
+ SELECT
35
+ #{bucket_sql} AS scheduled_at,
36
+ job_class,
37
+ SUM(duration) AS sum
38
+ FROM (#{inner_sql}) executions
39
+ CROSS JOIN range_parameters
40
+ GROUP BY #{bucket_sql}, job_class
41
+ )
42
+ SELECT *
43
+ FROM timestamps
44
+ LEFT JOIN sources ON sources.scheduled_at = timestamps.timestamp
23
45
  ORDER BY timestamp ASC
24
46
  SQL
25
47
 
26
- executions_data = GoodJob::Job.connection_pool.with_connection { |conn| conn.exec_query(GoodJob::Job.pg_or_jdbc_query(sum_query), "GoodJob Performance Chart", start_end_binds) }
48
+ executions_data = GoodJob::Job.connection_pool.with_connection { |conn| conn.exec_query(GoodJob::Job.pg_or_jdbc_query(sum_query), "GoodJob Performance Chart", binds) }
27
49
 
28
50
  job_names = executions_data.reject { |d| d['sum'].nil? }.map { |d| d['job_class'] || BaseFilter::EMPTY }.uniq
29
- labels = []
51
+ timestamp_values = []
52
+ timestamps = []
30
53
  jobs_data = executions_data.to_a.group_by { |d| d['timestamp'] }.each_with_object({}) do |(timestamp, values), hash|
31
- labels << timestamp.in_time_zone.strftime('%H:%M')
54
+ timestamp_values << timestamp
55
+ timestamps << @range.canonical_timestamp(timestamp)
32
56
  job_names.each do |job_class|
33
57
  sum = values.find { |d| d['job_class'] == job_class }&.[]('sum')
34
58
  duration = sum ? ActiveSupport::Duration.parse(sum).to_f : 0
35
59
  (hash[job_class] ||= []) << duration
36
60
  end
37
61
  end
62
+ labels = @range.chart_timestamp_labels(timestamp_values)
38
63
 
39
64
  {
40
65
  type: "line",
@@ -66,6 +91,14 @@ module GoodJob
66
91
  },
67
92
  },
68
93
  },
94
+ goodJob: {
95
+ interval_seconds: @range.interval_seconds,
96
+ range_end: @range.canonical_timestamp(@range.end_time),
97
+ range_start: @range.canonical_timestamp(@range.start_time),
98
+ timestamp_label_style: @range.label_style,
99
+ time_series: true,
100
+ timestamps: timestamps,
101
+ },
69
102
  }
70
103
  end
71
104
  end
@@ -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,9 +12,10 @@ 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)
16
16
  super()
17
17
  @job_class = job_class
18
+ @range = range
18
19
  end
19
20
 
20
21
  def data
@@ -27,7 +28,7 @@ module GoodJob
27
28
  COUNT(WIDTH_BUCKET(duration, ARRAY[#{interval_entries}])) AS count
28
29
  FROM #{table_name} sources
29
30
  WHERE
30
- scheduled_at > $1::timestamp
31
+ scheduled_at >= $1::timestamp
31
32
  AND scheduled_at < $2::timestamp
32
33
  AND job_class = $3
33
34
  AND duration IS NOT NULL
@@ -35,7 +36,7 @@ module GoodJob
35
36
  SQL
36
37
 
37
38
  binds = [
38
- *start_end_binds,
39
+ *@range.start_end_binds,
39
40
  @job_class,
40
41
  ]
41
42
  labels = BUCKET_INTERVALS.map { |interval| GoodJob::ApplicationController.helpers.format_duration(interval) }
@@ -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,8 +2,12 @@
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("
8
+ executions = @performance_range.apply(GoodJob::Execution)
9
+
10
+ @performances = executions.group(:job_class).select("
7
11
  job_class,
8
12
  COUNT(*) AS executions_count,
9
13
  AVG(duration) AS avg_duration,
@@ -11,18 +15,39 @@ module GoodJob
11
15
  MAX(duration) AS max_duration
12
16
  ").order(:job_class)
13
17
 
14
- @queue_performances = GoodJob::Execution.group(:queue_name).select("
18
+ @queue_performances = executions.group(:queue_name).select("
15
19
  queue_name,
16
20
  COUNT(*) AS executions_count,
17
21
  AVG(duration) AS avg_duration,
18
22
  MIN(duration) AS min_duration,
19
23
  MAX(duration) AS max_duration
20
24
  ").order(:queue_name)
25
+
26
+ @chart_data = GoodJob::PerformanceIndexChart.new(@performance_range).data
21
27
  end
22
28
 
23
29
  def show
24
- representative_job = GoodJob::Job.find_by!(job_class: params[:id])
30
+ representative_job = GoodJob::Job.find_by!(job_class: request.path_parameters.fetch(:id))
25
31
  @job_class = representative_job.job_class
32
+ @chart_data = GoodJob::PerformanceShowChart.new(@job_class, @performance_range).data
33
+ end
34
+
35
+ private
36
+
37
+ def set_performance_range
38
+ locale = request.query_parameters["locale"]
39
+ @performance_range_context = locale.is_a?(String) ? { "locale" => locale } : {}
40
+ @performance_range = GoodJob::PerformanceRange.new(params, query_string: request.query_string)
41
+ return if @performance_range.canonical_parameters?(request.query_parameters)
42
+
43
+ canonical_query = @performance_range.to_params.symbolize_keys
44
+ canonical_query[:locale] = @performance_range_context["locale"]
45
+ canonical_path = if action_name == "show"
46
+ performance_path(request.path_parameters.fetch(:id), canonical_query)
47
+ else
48
+ performance_index_path(canonical_query)
49
+ end
50
+ redirect_to canonical_path
26
51
  end
27
52
  end
28
53
  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";