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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +115 -0
- data/README.md +65 -8
- data/app/charts/good_job/latency_metric/base.rb +21 -0
- data/app/charts/good_job/latency_metric/execution.rb +26 -0
- data/app/charts/good_job/latency_metric/queue.rb +22 -0
- data/app/charts/good_job/latency_metric/total.rb +22 -0
- data/app/charts/good_job/latency_metric.rb +42 -0
- data/app/charts/good_job/performance_index_chart.rb +57 -22
- data/app/charts/good_job/performance_range.rb +380 -0
- data/app/charts/good_job/performance_show_chart.rb +15 -6
- data/app/charts/good_job/scheduled_by_queue_chart.rb +2 -2
- data/app/controllers/good_job/cleaner_controller.rb +1 -1
- data/app/controllers/good_job/performance_controller.rb +35 -16
- data/app/filters/good_job/base_filter.rb +12 -10
- data/app/filters/good_job/batches_filter.rb +12 -2
- data/app/frontend/good_job/application.js +2 -0
- data/app/frontend/good_job/modules/async_values_controller.js +7 -0
- data/app/frontend/good_job/modules/chart_controller.js +252 -2
- data/app/frontend/good_job/modules/live_poll_controller.js +16 -1
- data/app/frontend/good_job/modules/performance_range_controller.js +321 -0
- data/app/frontend/good_job/modules/timezone_name_formatter.js +13 -0
- data/app/frontend/good_job/style.css +102 -0
- data/app/helpers/good_job/application_helper.rb +34 -0
- data/app/models/concerns/good_job/advisory_lockable.rb +13 -0
- data/app/models/concerns/good_job/filterable.rb +19 -6
- data/app/models/good_job/batch.rb +2 -4
- data/app/models/good_job/concurrency_claim.rb +162 -0
- data/app/models/good_job/cron_entry.rb +66 -17
- data/app/models/good_job/job.rb +47 -7
- data/app/models/good_job/process.rb +11 -8
- data/app/models/good_job/setting.rb +16 -4
- data/app/views/good_job/batches/_jobs.erb +1 -1
- data/app/views/good_job/batches/_table.erb +1 -1
- data/app/views/good_job/batches/show.html.erb +1 -1
- data/app/views/good_job/cron_entries/index.html.erb +1 -1
- data/app/views/good_job/jobs/_executions.erb +1 -1
- data/app/views/good_job/jobs/_table.erb +1 -1
- data/app/views/good_job/jobs/show.html.erb +2 -2
- data/app/views/good_job/performance/_latency.erb +6 -0
- data/app/views/good_job/performance/_metric.erb +8 -0
- data/app/views/good_job/performance/_range.erb +91 -0
- data/app/views/good_job/performance/index.html.erb +52 -50
- data/app/views/good_job/performance/show.html.erb +6 -2
- data/app/views/good_job/shared/_chart_container.erb +3 -1
- data/config/brakeman.ignore +38 -32
- data/config/locales/az.yml +337 -0
- data/config/locales/de.yml +44 -4
- data/config/locales/en.yml +44 -4
- data/config/locales/es.yml +44 -4
- data/config/locales/fr.yml +44 -4
- data/config/locales/it.yml +44 -4
- data/config/locales/ja.yml +44 -4
- data/config/locales/ko.yml +44 -4
- data/config/locales/nl.yml +44 -4
- data/config/locales/pt-BR.yml +44 -4
- data/config/locales/ru.yml +60 -20
- data/config/locales/tr.yml +44 -4
- data/config/locales/uk.yml +58 -18
- data/config/locales/zh-CN.yml +44 -4
- data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +37 -6
- data/lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb.erb +11 -6
- data/lib/generators/good_job/templates/update/migrations/15_add_index_good_jobs_discarded_job_class.rb.erb +13 -0
- data/lib/generators/good_job/templates/update/migrations/16_add_index_good_jobs_dequeue_by_queue.rb.erb +21 -0
- data/lib/generators/good_job/templates/update/migrations/17_add_index_good_job_executions_on_scheduled_at.rb.erb +12 -0
- data/lib/generators/good_job/templates/update/migrations/18_add_batch_query_indexes.rb.erb +28 -0
- data/lib/generators/good_job/templates/update/migrations/19_remove_redundant_good_jobs_indexes.rb.erb +55 -0
- data/lib/generators/good_job/templates/update/migrations/20_reorder_good_jobs_cron_index.rb.erb +51 -0
- data/lib/generators/good_job/templates/update/migrations/21_create_good_job_concurrency_claims.rb.erb +27 -0
- data/lib/good_job/active_job_extensions/concurrency.rb +128 -42
- data/lib/good_job/active_job_extensions/labels.rb +4 -1
- data/lib/good_job/adapter.rb +12 -7
- data/lib/good_job/bulk.rb +1 -2
- data/lib/good_job/capsule.rb +1 -1
- data/lib/good_job/capsule_tracker.rb +84 -55
- data/lib/good_job/cli.rb +32 -1
- data/lib/good_job/configuration/validator.rb +42 -0
- data/lib/good_job/configuration.rb +104 -3
- data/lib/good_job/cron_manager.rb +31 -7
- data/lib/good_job/lifecycle_hooks.rb +83 -0
- data/lib/good_job/log_subscriber.rb +78 -0
- data/lib/good_job/multi_scheduler.rb +2 -1
- data/lib/good_job/notifier/process_heartbeat.rb +30 -6
- data/lib/good_job/notifier.rb +5 -3
- data/lib/good_job/probe_server/cluster_healthcheck_middleware.rb +32 -0
- data/lib/good_job/probe_server/simple_handler.rb +14 -9
- data/lib/good_job/probe_server/webrick_handler.rb +12 -0
- data/lib/good_job/probe_server.rb +30 -2
- data/lib/good_job/shared_executor.rb +3 -1
- data/lib/good_job/subprocess.rb +135 -0
- data/lib/good_job/supervisor.rb +536 -0
- data/lib/good_job/systemd_service.rb +12 -1
- data/lib/good_job/version.rb +1 -1
- data/lib/good_job.rb +19 -5
- metadata +41 -3
- 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
|
-
|
|
27
|
-
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
|
|
33
|
+
scheduled_at >= $1::timestamp
|
|
31
34
|
AND scheduled_at < $2::timestamp
|
|
32
35
|
AND job_class = $3
|
|
33
|
-
AND
|
|
36
|
+
AND #{@metric.presence_sql}
|
|
34
37
|
GROUP BY bucket_index
|
|
35
38
|
SQL
|
|
36
39
|
|
|
37
40
|
binds = [
|
|
38
|
-
|
|
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
|
-
|
|
21
|
-
AND
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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:
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 =
|
|
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
|
-
|
|
14
|
+
base_query
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def default_base_query
|
|
14
|
-
GoodJob::BatchRecord.
|
|
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() {
|