good_job 4.19.1 → 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 +67 -1
- data/README.md +5 -5
- 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/concerns/good_job/advisory_lockable.rb +3 -3
- data/app/models/good_job/batch.rb +6 -0
- data/app/models/good_job/cron_entry.rb +34 -4
- data/app/models/good_job/job.rb +7 -6
- 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 +13 -4
- 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,55 @@
|
|
|
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
|
+
|
|
31
|
+
## [v4.19.2](https://github.com/bensheldon/good_job/tree/v4.19.2) (2026-07-20)
|
|
32
|
+
|
|
33
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.1...v4.19.2)
|
|
34
|
+
|
|
35
|
+
**Fixed bugs:**
|
|
36
|
+
|
|
37
|
+
- Resolve connection pool at call time in the `adapter_class` shim [\#1798](https://github.com/bensheldon/good_job/pull/1798) ([DaisyOli](https://github.com/DaisyOli))
|
|
38
|
+
- Add GoodJob::Batch.where\(id:\) for GlobalID::Locator.fetch [\#1792](https://github.com/bensheldon/good_job/pull/1792) ([bensheldon](https://github.com/bensheldon))
|
|
39
|
+
- Preserve created\_at when a job is retried [\#1789](https://github.com/bensheldon/good_job/pull/1789) ([ollym](https://github.com/ollym))
|
|
40
|
+
|
|
41
|
+
**Closed issues:**
|
|
42
|
+
|
|
43
|
+
- Memoized `adapter_class` proxy captures the pre-fork connection pool, crashing the Notifier in forked Puma workers \(Rails \< 7.2 + `preload_app!`\) [\#1797](https://github.com/bensheldon/good_job/issues/1797)
|
|
44
|
+
- created\_at is reset every time a job is retried [\#1788](https://github.com/bensheldon/good_job/issues/1788)
|
|
45
|
+
|
|
46
|
+
**Merged pull requests:**
|
|
47
|
+
|
|
48
|
+
- Fix JRuby CI failure installing `rbs` gem [\#1794](https://github.com/bensheldon/good_job/pull/1794) ([bensheldon](https://github.com/bensheldon))
|
|
49
|
+
- Fix flaky scheduled\_at precision comparison in adapter\_spec [\#1793](https://github.com/bensheldon/good_job/pull/1793) ([bensheldon](https://github.com/bensheldon))
|
|
50
|
+
- Remove inline\_execution\_respects\_schedule config [\#1791](https://github.com/bensheldon/good_job/pull/1791) ([bensheldon](https://github.com/bensheldon))
|
|
51
|
+
- Bump actions/checkout from 6 to 7 [\#1787](https://github.com/bensheldon/good_job/pull/1787) ([dependabot[bot]](https://github.com/apps/dependabot))
|
|
52
|
+
|
|
3
53
|
## [v4.19.1](https://github.com/bensheldon/good_job/tree/v4.19.1) (2026-06-25)
|
|
4
54
|
|
|
5
55
|
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.0...v4.19.1)
|
|
@@ -134,6 +184,10 @@
|
|
|
134
184
|
|
|
135
185
|
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.16.0...v4.17.0)
|
|
136
186
|
|
|
187
|
+
**Implemented enhancements:**
|
|
188
|
+
|
|
189
|
+
- Introduce advisory lock key customization support methods [\#1722](https://github.com/bensheldon/good_job/pull/1722) ([amkisko](https://github.com/amkisko))
|
|
190
|
+
|
|
137
191
|
**Merged pull requests:**
|
|
138
192
|
|
|
139
193
|
- Convert UI JavaScript modules to Stimulus controllers [\#1743](https://github.com/bensheldon/good_job/pull/1743) ([bensheldon](https://github.com/bensheldon))
|
|
@@ -149,6 +203,11 @@
|
|
|
149
203
|
- Allow filtering by label on dashboard [\#1739](https://github.com/bensheldon/good_job/pull/1739) ([bensheldon](https://github.com/bensheldon))
|
|
150
204
|
- Allow multiple concurrency rules per job via labels [\#1700](https://github.com/bensheldon/good_job/pull/1700) ([bscofield](https://github.com/bscofield))
|
|
151
205
|
|
|
206
|
+
**Fixed bugs:**
|
|
207
|
+
|
|
208
|
+
- Fix advisory lock connection stickiness in block contexts [\#1736](https://github.com/bensheldon/good_job/pull/1736) ([bensheldon](https://github.com/bensheldon))
|
|
209
|
+
- Add JRuby 10 to testing matrix [\#1559](https://github.com/bensheldon/good_job/pull/1559) ([bensheldon](https://github.com/bensheldon))
|
|
210
|
+
|
|
152
211
|
**Closed issues:**
|
|
153
212
|
|
|
154
213
|
- Job duration misreported if interrupted [\#1723](https://github.com/bensheldon/good_job/issues/1723)
|
|
@@ -158,6 +217,7 @@
|
|
|
158
217
|
- Use annotated git tag in release script [\#1741](https://github.com/bensheldon/good_job/pull/1741) ([bensheldon](https://github.com/bensheldon))
|
|
159
218
|
- Double single-thread scheduler integration test timeout on JRuby [\#1738](https://github.com/bensheldon/good_job/pull/1738) ([bensheldon](https://github.com/bensheldon))
|
|
160
219
|
- Fix JRuby test flakes for scheduler timeout and interrupted execution duration [\#1737](https://github.com/bensheldon/good_job/pull/1737) ([bensheldon](https://github.com/bensheldon))
|
|
220
|
+
- Count Advisory Locks and refactor advisory lock lifecycle [\#1735](https://github.com/bensheldon/good_job/pull/1735) ([bensheldon](https://github.com/bensheldon))
|
|
161
221
|
- Show interrupted execution recovery duration in dashboard [\#1733](https://github.com/bensheldon/good_job/pull/1733) ([bensheldon](https://github.com/bensheldon))
|
|
162
222
|
- chore: use `merge` to avoid mutate the query object [\#1717](https://github.com/bensheldon/good_job/pull/1717) ([luizkowalski](https://github.com/luizkowalski))
|
|
163
223
|
|
|
@@ -173,6 +233,7 @@
|
|
|
173
233
|
|
|
174
234
|
**Merged pull requests:**
|
|
175
235
|
|
|
236
|
+
- Fix JRuby in development lockfile, with test [\#1734](https://github.com/bensheldon/good_job/pull/1734) ([bensheldon](https://github.com/bensheldon))
|
|
176
237
|
- Add herb to linter [\#1732](https://github.com/bensheldon/good_job/pull/1732) ([bensheldon](https://github.com/bensheldon))
|
|
177
238
|
- Update development dependencies; apply Rubocop to\_h lints [\#1728](https://github.com/bensheldon/good_job/pull/1728) ([bensheldon](https://github.com/bensheldon))
|
|
178
239
|
|
|
@@ -234,6 +295,7 @@
|
|
|
234
295
|
|
|
235
296
|
**Fixed bugs:**
|
|
236
297
|
|
|
298
|
+
- Check for graceful shutdown inside job cleanup loops [\#1711](https://github.com/bensheldon/good_job/pull/1711) ([bdewater-thatch](https://github.com/bdewater-thatch))
|
|
237
299
|
- Add title to Good Job Dashboard layout [\#1701](https://github.com/bensheldon/good_job/pull/1701) ([mockdeep](https://github.com/mockdeep))
|
|
238
300
|
|
|
239
301
|
**Closed issues:**
|
|
@@ -329,6 +391,7 @@
|
|
|
329
391
|
**Merged pull requests:**
|
|
330
392
|
|
|
331
393
|
- Update sorbet/tapioca [\#1681](https://github.com/bensheldon/good_job/pull/1681) ([bensheldon](https://github.com/bensheldon))
|
|
394
|
+
- Remove obsolete property from tests [\#1676](https://github.com/bensheldon/good_job/pull/1676) ([RDIL](https://github.com/RDIL))
|
|
332
395
|
- Bump actions/checkout from 4 to 5 [\#1673](https://github.com/bensheldon/good_job/pull/1673) ([dependabot[bot]](https://github.com/apps/dependabot))
|
|
333
396
|
|
|
334
397
|
## [v4.11.2](https://github.com/bensheldon/good_job/tree/v4.11.2) (2025-08-06)
|
|
@@ -451,6 +514,10 @@
|
|
|
451
514
|
|
|
452
515
|
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.9.0...v4.9.1)
|
|
453
516
|
|
|
517
|
+
**Implemented enhancements:**
|
|
518
|
+
|
|
519
|
+
- Order Dashboard jobs in more "natural" order [\#1604](https://github.com/bensheldon/good_job/pull/1604) ([francois](https://github.com/francois))
|
|
520
|
+
|
|
454
521
|
**Fixed bugs:**
|
|
455
522
|
|
|
456
523
|
- \[dashboard\] Scheduled tasks are shown "backwards" [\#1580](https://github.com/bensheldon/good_job/issues/1580)
|
|
@@ -1936,7 +2003,6 @@
|
|
|
1936
2003
|
- In tests, directly use `connection.truncate_tables` instead of `ActiveRecord::Tasks::DatabaseTasks.truncate_all` [\#918](https://github.com/bensheldon/good_job/pull/918) ([bensheldon](https://github.com/bensheldon))
|
|
1937
2004
|
- Use `GoodJob.capsule` in CLI instead of creating a new Capsule [\#916](https://github.com/bensheldon/good_job/pull/916) ([bensheldon](https://github.com/bensheldon))
|
|
1938
2005
|
- Fix lint: missing fr and nl translations; readme whitespace [\#914](https://github.com/bensheldon/good_job/pull/914) ([bensheldon](https://github.com/bensheldon))
|
|
1939
|
-
- Switches from Chrome to Chromium to support Apple M1/Arm CPUs [\#910](https://github.com/bensheldon/good_job/pull/910) ([ckdake](https://github.com/ckdake))
|
|
1940
2006
|
- Bump erb\_lint from 0.3.1 to 0.4.0 [\#906](https://github.com/bensheldon/good_job/pull/906) ([dependabot[bot]](https://github.com/apps/dependabot))
|
|
1941
2007
|
|
|
1942
2008
|
## [v3.15.0](https://github.com/bensheldon/good_job/tree/v3.15.0) (2023-04-02)
|
data/README.md
CHANGED
|
@@ -315,7 +315,6 @@ Available configuration options are:
|
|
|
315
315
|
- `cleanup_preserved_jobs_before_seconds_ago` (integer) number of seconds to preserve jobs when using the `$ good_job cleanup_preserved_jobs` CLI command or calling `GoodJob.cleanup_preserved_jobs`. Defaults to `1209600` (14 days). Can also be set with the environment variable `GOOD_JOB_CLEANUP_PRESERVED_JOBS_BEFORE_SECONDS_AGO`.
|
|
316
316
|
- `cleanup_interval_jobs` (integer) Number of jobs a Scheduler will execute before cleaning up preserved jobs. Defaults to `1000`. Disable with `false`. Can also be set with the environment variable `GOOD_JOB_CLEANUP_INTERVAL_JOBS` and disabled with `0`).
|
|
317
317
|
- `cleanup_interval_seconds` (integer) Number of seconds a Scheduler will wait before cleaning up preserved jobs. Defaults to `600` (10 minutes). Disable with `false`. Can also be set with the environment variable `GOOD_JOB_CLEANUP_INTERVAL_SECONDS` and disabled with `0`).
|
|
318
|
-
- `inline_execution_respects_schedule` (boolean) Opt-in to future behavior of inline execution respecting scheduled jobs. Defaults to `false`.
|
|
319
318
|
- `logger` ([Rails Logger](https://api.rubyonrails.org/classes/ActiveSupport/Logger.html)) lets you set a custom logger for GoodJob. It should be an instance of a Rails `Logger` (Default: `Rails.logger`).
|
|
320
319
|
- `preserve_job_records` (boolean, symbol, or lambda) keeps job records in your database even after jobs are completed. If set to `true`, all job records are preserved. If set to `:on_unhandled_error`, only jobs that finished with an unhandled error are preserved. If set to a lambda, the lambda will be called with the error_event (e.g., `:discarded`, `:retry_stopped`, or `:unhandled`) and should return a boolean indicating whether to preserve the job. (Default: `true`)
|
|
321
320
|
- `advisory_lock_heartbeat` (boolean) whether to use an advisory lock for the purpose of determining whether an execeution process is active. (Default `true` in Development; `false` in other environments)
|
|
@@ -339,6 +338,9 @@ Available configuration options are:
|
|
|
339
338
|
```
|
|
340
339
|
|
|
341
340
|
- `enable_pauses` (boolean) whether job processing can be paused. Defaults to `false`. You can also set this with the environment variable `GOOD_JOB_ENABLE_PAUSES`.
|
|
341
|
+
- `dequeue_query_sort` (symbol) sets the sort order used when querying for the next job(s) to execute. Defaults to `:created_at`. It can be any one of:
|
|
342
|
+
- `:created_at` sorts by priority, then job creation order. This is the default, but no longer best matches GoodJob's contemporary data model.
|
|
343
|
+
- `:scheduled_at` sorts by priority, then `scheduled_at`, then `id`, which better matches GoodJob's contemporary data model. This will become the default/only behavior in a future major release.
|
|
342
344
|
|
|
343
345
|
By default, GoodJob configures the following execution modes per environment:
|
|
344
346
|
|
|
@@ -1505,9 +1507,7 @@ It is also possible to manually trigger a cleanup of preserved job records:
|
|
|
1505
1507
|
|
|
1506
1508
|
By default, GoodJob uses its inline adapter in the test environment; the inline adapter is designed for the test environment. When enqueuing a job with GoodJob's inline adapter, the job will be executed immediately on the current thread; unhandled exceptions will be raised.
|
|
1507
1509
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
To opt into this behavior immediately set: `config.good_job.inline_execution_respects_schedule = true`
|
|
1510
|
+
The inline adapter respects scheduled jobs: jobs scheduled for the future are enqueued in the database rather than executed immediately.
|
|
1511
1511
|
|
|
1512
1512
|
To perform jobs inline at any time, use `GoodJob.perform_inline`. For example, using time helpers within an integration test:
|
|
1513
1513
|
|
|
@@ -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
|