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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +1 -1
- data/app/charts/good_job/performance_index_chart.rb +50 -17
- data/app/charts/good_job/performance_range.rb +380 -0
- data/app/charts/good_job/performance_show_chart.rb +4 -3
- 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 +28 -3
- data/app/frontend/good_job/application.js +2 -0
- data/app/frontend/good_job/modules/chart_controller.js +252 -2
- 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 +97 -0
- data/app/helpers/good_job/application_helper.rb +16 -0
- data/app/models/good_job/cron_entry.rb +34 -4
- data/app/models/good_job/setting.rb +16 -4
- data/app/views/good_job/performance/_range.erb +91 -0
- data/app/views/good_job/performance/index.html.erb +17 -5
- data/app/views/good_job/performance/show.html.erb +4 -2
- data/config/locales/az.yml +321 -0
- data/config/locales/de.yml +24 -0
- data/config/locales/en.yml +24 -0
- data/config/locales/es.yml +24 -0
- data/config/locales/fr.yml +24 -0
- data/config/locales/it.yml +24 -0
- data/config/locales/ja.yml +24 -0
- data/config/locales/ko.yml +24 -0
- data/config/locales/nl.yml +24 -0
- data/config/locales/pt-BR.yml +24 -0
- data/config/locales/ru.yml +24 -0
- data/config/locales/tr.yml +24 -0
- data/config/locales/uk.yml +24 -0
- data/config/locales/zh-CN.yml +24 -0
- data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +3 -0
- data/lib/generators/good_job/templates/update/migrations/15_add_index_good_jobs_discarded_job_class.rb.erb +13 -0
- data/lib/good_job/adapter.rb +12 -7
- data/lib/good_job/configuration/validator.rb +42 -0
- data/lib/good_job/configuration.rb +12 -0
- data/lib/good_job/probe_server.rb +12 -2
- data/lib/good_job/version.rb +1 -1
- data/lib/good_job.rb +3 -3
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4d6ba03812a521f55ab70bb95caafb96aba83c049036e9f8cc5d7489f3f8dcc
|
|
4
|
+
data.tar.gz: 3898461e6eccf9e8c6106b69a41123b82ff2e5a4697205464c60336304b4a5ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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 =
|
|
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:
|
|
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";
|