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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/README.md +5 -5
  4. data/app/charts/good_job/performance_index_chart.rb +50 -17
  5. data/app/charts/good_job/performance_range.rb +380 -0
  6. data/app/charts/good_job/performance_show_chart.rb +4 -3
  7. data/app/charts/good_job/scheduled_by_queue_chart.rb +2 -2
  8. data/app/controllers/good_job/cleaner_controller.rb +1 -1
  9. data/app/controllers/good_job/performance_controller.rb +28 -3
  10. data/app/frontend/good_job/application.js +2 -0
  11. data/app/frontend/good_job/modules/chart_controller.js +252 -2
  12. data/app/frontend/good_job/modules/performance_range_controller.js +321 -0
  13. data/app/frontend/good_job/modules/timezone_name_formatter.js +13 -0
  14. data/app/frontend/good_job/style.css +97 -0
  15. data/app/helpers/good_job/application_helper.rb +16 -0
  16. data/app/models/concerns/good_job/advisory_lockable.rb +3 -3
  17. data/app/models/good_job/batch.rb +6 -0
  18. data/app/models/good_job/cron_entry.rb +34 -4
  19. data/app/models/good_job/job.rb +7 -6
  20. data/app/models/good_job/setting.rb +16 -4
  21. data/app/views/good_job/performance/_range.erb +91 -0
  22. data/app/views/good_job/performance/index.html.erb +17 -5
  23. data/app/views/good_job/performance/show.html.erb +4 -2
  24. data/config/locales/az.yml +321 -0
  25. data/config/locales/de.yml +24 -0
  26. data/config/locales/en.yml +24 -0
  27. data/config/locales/es.yml +24 -0
  28. data/config/locales/fr.yml +24 -0
  29. data/config/locales/it.yml +24 -0
  30. data/config/locales/ja.yml +24 -0
  31. data/config/locales/ko.yml +24 -0
  32. data/config/locales/nl.yml +24 -0
  33. data/config/locales/pt-BR.yml +24 -0
  34. data/config/locales/ru.yml +24 -0
  35. data/config/locales/tr.yml +24 -0
  36. data/config/locales/uk.yml +24 -0
  37. data/config/locales/zh-CN.yml +24 -0
  38. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +3 -0
  39. data/lib/generators/good_job/templates/update/migrations/15_add_index_good_jobs_discarded_job_class.rb.erb +13 -0
  40. data/lib/good_job/adapter.rb +12 -7
  41. data/lib/good_job/configuration/validator.rb +42 -0
  42. data/lib/good_job/configuration.rb +13 -4
  43. data/lib/good_job/probe_server.rb +12 -2
  44. data/lib/good_job/version.rb +1 -1
  45. data/lib/good_job.rb +3 -3
  46. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7afb27ab67bbbe17bf951784355a40adf87515486c7fd28c5cb12af3e2135005
4
- data.tar.gz: 9f1da0f02768f9131a3c75ff1d181170a169662f631a83f05862608c579a30e6
3
+ metadata.gz: b4d6ba03812a521f55ab70bb95caafb96aba83c049036e9f8cc5d7489f3f8dcc
4
+ data.tar.gz: 3898461e6eccf9e8c6106b69a41123b82ff2e5a4697205464c60336304b4a5ef
5
5
  SHA512:
6
- metadata.gz: 4e9e6c72eba0c338334e2acb94cc87e2b5f433ac2b36fd2ec05a1a58656dc5c6ff7d5f4d22f218aeaa04b0f432a44d908654ab0c25dd7c82194acb505539952f
7
- data.tar.gz: 912da2530fec05469474232f8246f9370dd1da24e822bcf6bcb37b6f649b43ab6d0836d5999a6105eb06f999cc26bde9e94601280093451becf34c2daeb76598
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
- In GoodJob 2.0, the inline adapter will execute future scheduled jobs immediately. In the next major release, GoodJob 3.0, the inline adapter will not execute future scheduled jobs and instead enqueue them in the database.
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
- table_name = GoodJob::Execution.table_name
11
+ binds = @range.time_series_binds
12
+ bucket_sql = @range.time_series_bucket_sql(
13
+ "scheduled_at",
14
+ start_expression: "range_parameters.series_start_time",
15
+ interval_expression: "range_parameters.interval_seconds"
16
+ )
17
+ inner_sql = @range.apply(GoodJob::Execution).select(:job_class, :scheduled_at, :duration).to_sql
7
18
 
19
+ # Keep each bind placeholder single-use because JDBC binds every converted occurrence.
8
20
  sum_query = <<~SQL.squish
9
- SELECT *
10
- FROM generate_series(
11
- date_trunc('hour', $1::timestamp),
12
- date_trunc('hour', $2::timestamp),
13
- '1 hour'
14
- ) timestamp
15
- LEFT JOIN (
21
+ WITH range_parameters AS (
16
22
  SELECT
17
- date_trunc('hour', scheduled_at) AS scheduled_at,
18
- job_class,
19
- SUM(duration) AS sum
20
- FROM #{table_name} sources
21
- GROUP BY date_trunc('hour', scheduled_at), job_class
22
- ) sources ON sources.scheduled_at = timestamp
23
+ $1::timestamp AS series_start_time,
24
+ $2::timestamp AS series_end_time,
25
+ $3::bigint AS interval_seconds
26
+ ), timestamps AS (
27
+ SELECT generate_series(
28
+ series_start_time,
29
+ series_end_time,
30
+ interval_seconds * INTERVAL '1 second'
31
+ ) AS timestamp
32
+ FROM range_parameters
33
+ ), sources AS (
34
+ SELECT
35
+ #{bucket_sql} AS scheduled_at,
36
+ job_class,
37
+ SUM(duration) AS sum
38
+ FROM (#{inner_sql}) executions
39
+ CROSS JOIN range_parameters
40
+ GROUP BY #{bucket_sql}, job_class
41
+ )
42
+ SELECT *
43
+ FROM timestamps
44
+ LEFT JOIN sources ON sources.scheduled_at = timestamps.timestamp
23
45
  ORDER BY timestamp ASC
24
46
  SQL
25
47
 
26
- executions_data = GoodJob::Job.connection_pool.with_connection { |conn| conn.exec_query(GoodJob::Job.pg_or_jdbc_query(sum_query), "GoodJob Performance Chart", start_end_binds) }
48
+ executions_data = GoodJob::Job.connection_pool.with_connection { |conn| conn.exec_query(GoodJob::Job.pg_or_jdbc_query(sum_query), "GoodJob Performance Chart", binds) }
27
49
 
28
50
  job_names = executions_data.reject { |d| d['sum'].nil? }.map { |d| d['job_class'] || BaseFilter::EMPTY }.uniq
29
- labels = []
51
+ timestamp_values = []
52
+ timestamps = []
30
53
  jobs_data = executions_data.to_a.group_by { |d| d['timestamp'] }.each_with_object({}) do |(timestamp, values), hash|
31
- labels << timestamp.in_time_zone.strftime('%H:%M')
54
+ timestamp_values << timestamp
55
+ timestamps << @range.canonical_timestamp(timestamp)
32
56
  job_names.each do |job_class|
33
57
  sum = values.find { |d| d['job_class'] == job_class }&.[]('sum')
34
58
  duration = sum ? ActiveSupport::Duration.parse(sum).to_f : 0
35
59
  (hash[job_class] ||= []) << duration
36
60
  end
37
61
  end
62
+ labels = @range.chart_timestamp_labels(timestamp_values)
38
63
 
39
64
  {
40
65
  type: "line",
@@ -66,6 +91,14 @@ module GoodJob
66
91
  },
67
92
  },
68
93
  },
94
+ goodJob: {
95
+ interval_seconds: @range.interval_seconds,
96
+ range_end: @range.canonical_timestamp(@range.end_time),
97
+ range_start: @range.canonical_timestamp(@range.start_time),
98
+ timestamp_label_style: @range.label_style,
99
+ time_series: true,
100
+ timestamps: timestamps,
101
+ },
69
102
  }
70
103
  end
71
104
  end
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob
4
+ class PerformanceRange
5
+ PARAMETER_KEYS = %w[chart_range chart_start chart_end].freeze
6
+ TIME_ZONE_PARAMETER_KEY = "chart_time_zone"
7
+ INPUT_PARAMETER_KEYS = [*PARAMETER_KEYS, TIME_ZONE_PARAMETER_KEY].freeze
8
+ DEFAULT_KEY = "24h"
9
+ MAXIMUM_TIME_SERIES_COORDINATES = 30
10
+ MAXIMUM_TIME_ZONE_LENGTH = 255
11
+ # Restrict custom bounds to portable four-digit years in the timezone used by the page.
12
+ MINIMUM_YEAR = 1000
13
+ MAXIMUM_YEAR = 9999
14
+ MINIMUM_LOCAL_VALUE = "1000-01-01T00:00:00"
15
+ MAXIMUM_LOCAL_VALUE = "9999-12-31T23:59:59"
16
+ TIMESTAMP_PATTERN = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/
17
+ LOCAL_TIMESTAMP_PATTERN = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?\z/
18
+
19
+ OPTIONS = {
20
+ "1h" => {
21
+ label: "1h",
22
+ duration: 1.hour,
23
+ interval_seconds: 5.minutes.to_i,
24
+ label_format: "%H:%M",
25
+ label_style: "time",
26
+ },
27
+ "6h" => {
28
+ label: "6h",
29
+ duration: 6.hours,
30
+ interval_seconds: 15.minutes.to_i,
31
+ label_format: "%H:%M",
32
+ label_style: "time",
33
+ },
34
+ "24h" => {
35
+ label: "24h",
36
+ duration: 24.hours,
37
+ interval_seconds: 1.hour.to_i,
38
+ label_format: "%H:%M",
39
+ label_style: "time",
40
+ },
41
+ "7d" => {
42
+ label: "7d",
43
+ duration: 24.hours * 7,
44
+ interval_seconds: 6.hours.to_i,
45
+ label_format: "%b %-d %H:%M",
46
+ label_style: "date_time",
47
+ },
48
+ }.freeze
49
+
50
+ # All candidates become integer seconds. The 30-day and 365-day scales are fixed elapsed
51
+ # durations for readable long-range charts, not calendar month or year aggregation.
52
+ SEMANTIC_INTERVALS = [
53
+ *[1, 2, 5, 10, 15, 30].map(&:seconds),
54
+ *[1, 2, 5, 10, 15, 30].map(&:minutes),
55
+ *[1, 2, 3, 6, 12].map(&:hours),
56
+ *[1, 2, 3, 5, 7, 14].map(&:days),
57
+ *[1, 2, 3, 6].map { |multiple| multiple * 30.days },
58
+ *[1, 2, 5, 10, 20, 50, 100, 200, 500].map { |multiple| multiple * 365.days },
59
+ ].map(&:to_i).uniq.sort.freeze
60
+
61
+ attr_reader :end_time, :interval_seconds, :key, :label_format, :label_style, :start_time
62
+
63
+ def initialize(params = nil, query_string: nil, **parameter_keywords)
64
+ @params = params || parameter_keywords
65
+ @repeated_parameter_keys = repeated_parameter_keys(query_string)
66
+ @local_time_zone = local_time_zone
67
+ resolve
68
+ end
69
+
70
+ def apply(relation)
71
+ relation.where(scheduled_at: start_time...end_time)
72
+ end
73
+
74
+ def canonical_parameters?(query_parameters)
75
+ query_parameters.slice(*INPUT_PARAMETER_KEYS) == to_params
76
+ end
77
+
78
+ def chart_timestamp_label(timestamp)
79
+ timestamp.in_time_zone.strftime(label_format)
80
+ end
81
+
82
+ def chart_timestamp_labels(timestamps)
83
+ labels = timestamps.map { |timestamp| chart_timestamp_label(timestamp) }
84
+
85
+ labels.each_index.group_by { |index| labels[index] }.each_value do |indices|
86
+ next unless indices.many?
87
+
88
+ offsets = indices.map { |index| timestamps[index].in_time_zone.formatted_offset }
89
+ next unless offsets.uniq.many?
90
+
91
+ indices.zip(offsets).each do |index, offset|
92
+ labels[index] = "#{labels[index]} #{offset}"
93
+ end
94
+ end
95
+
96
+ labels
97
+ end
98
+
99
+ def canonical_timestamp(timestamp)
100
+ timestamp = timestamp.in_time_zone
101
+ # ISO 8601 cannot preserve the sub-minute historical offsets present in some IANA zones.
102
+ timestamp.utc_offset.remainder(60).zero? ? timestamp.iso8601 : timestamp.utc.iso8601
103
+ end
104
+
105
+ def custom?
106
+ key.nil?
107
+ end
108
+
109
+ def custom_params
110
+ {
111
+ "chart_start" => canonical_timestamp(start_time),
112
+ "chart_end" => canonical_timestamp(end_time),
113
+ }
114
+ end
115
+
116
+ def default?
117
+ to_params.empty?
118
+ end
119
+
120
+ def end_label
121
+ range_label(end_time)
122
+ end
123
+
124
+ def end_local_value
125
+ local_value(end_time)
126
+ end
127
+
128
+ def navigation_params
129
+ return to_params unless key
130
+
131
+ # A preset is a relative definition; navigation also carries this evaluated instance.
132
+ {
133
+ "chart_range" => key,
134
+ "chart_start" => canonical_timestamp(start_time),
135
+ "chart_end" => canonical_timestamp(end_time),
136
+ }
137
+ end
138
+
139
+ def options
140
+ OPTIONS.map { |option_key, option| { key: option_key, label: option.fetch(:label) } }
141
+ end
142
+
143
+ def reload_params
144
+ return to_params if custom? || default?
145
+
146
+ { "chart_range" => key }
147
+ end
148
+
149
+ def start_end_binds
150
+ [
151
+ query_attribute("start_time", start_time, ActiveRecord::Type::DateTime.new),
152
+ query_attribute("end_time", end_time, ActiveRecord::Type::DateTime.new),
153
+ ]
154
+ end
155
+
156
+ def start_label
157
+ range_label(start_time)
158
+ end
159
+
160
+ def start_local_value
161
+ local_value(start_time)
162
+ end
163
+
164
+ # Keep this order in sync with the $1/$2/$3 placeholders in PerformanceIndexChart.
165
+ def time_series_binds
166
+ [
167
+ query_attribute("series_start_time", series_start_time, ActiveRecord::Type::DateTime.new),
168
+ query_attribute("series_end_time", series_end_time, ActiveRecord::Type::DateTime.new),
169
+ query_attribute("interval_seconds", interval_seconds, ActiveRecord::Type::Integer.new(limit: 8)),
170
+ ]
171
+ end
172
+
173
+ def time_series_bucket_sql(column_name, start_expression:, interval_expression:)
174
+ column_sql = GoodJob::Job.adapter_class.quote_column_name(column_name)
175
+
176
+ <<~SQL.squish
177
+ #{start_expression} +
178
+ FLOOR(EXTRACT(EPOCH FROM (#{column_sql} - #{start_expression})) / #{interval_expression}) *
179
+ #{interval_expression} * INTERVAL '1 second'
180
+ SQL
181
+ end
182
+
183
+ def time_series_coordinate_count
184
+ coordinate_count(interval_seconds)
185
+ end
186
+
187
+ def to_params
188
+ @canonical_params.dup
189
+ end
190
+
191
+ private
192
+
193
+ def align_time(time, interval = interval_seconds)
194
+ # JRuby can misreport rational epochs for extreme years even when integer seconds are correct.
195
+ (time - (time.to_i % interval) - time.subsec).utc
196
+ end
197
+
198
+ def aligned_epoch_seconds(time, interval)
199
+ time.to_i.div(interval) * interval
200
+ end
201
+
202
+ def coordinate_count(interval)
203
+ first_coordinate = aligned_epoch_seconds(start_time, interval)
204
+ last_coordinate = aligned_epoch_seconds(end_time - Rational(1, 1_000_000), interval)
205
+
206
+ ((last_coordinate - first_coordinate) / interval) + 1
207
+ end
208
+
209
+ def custom_times
210
+ parsed_start = parse_time("chart_start", @params[:chart_start])
211
+ parsed_end = parse_time("chart_end", @params[:chart_end])
212
+ return unless parsed_start && parsed_end && parsed_start < parsed_end
213
+
214
+ [parsed_start, parsed_end]
215
+ end
216
+
217
+ def custom_interval_seconds
218
+ # Fall back to the coarsest interval rather than raising: MINIMUM_YEAR/MAXIMUM_YEAR bound the
219
+ # widest possible range today, but nothing enforces that bound against SEMANTIC_INTERVALS.last.
220
+ SEMANTIC_INTERVALS.find do |interval|
221
+ coordinate_count(interval) <= MAXIMUM_TIME_SERIES_COORDINATES
222
+ end || SEMANTIC_INTERVALS.last
223
+ end
224
+
225
+ def custom_label_options
226
+ elapsed_seconds = end_time - start_time
227
+
228
+ if elapsed_seconds >= 365.days
229
+ { label_format: "%b %-d, %Y %H:%M", label_style: "date_time_year" }
230
+ elsif elapsed_seconds >= 24.hours
231
+ { label_format: "%b %-d %H:%M", label_style: "date_time" }
232
+ elsif interval_seconds < 1.minute
233
+ { label_format: "%H:%M:%S", label_style: "time_seconds" }
234
+ else
235
+ { label_format: "%H:%M", label_style: "time" }
236
+ end
237
+ end
238
+
239
+ def parse_time(parameter_key, value)
240
+ return if @repeated_parameter_keys.include?(parameter_key)
241
+ return unless value.is_a?(String)
242
+
243
+ timestamp = if TIMESTAMP_PATTERN.match?(value)
244
+ Time.iso8601(value)
245
+ elsif (local_match = LOCAL_TIMESTAMP_PATTERN.match(value))
246
+ parse_local_time(parameter_key, local_match)
247
+ end
248
+ return unless timestamp
249
+ return unless timestamp.to_f.finite?
250
+
251
+ timestamp = timestamp.in_time_zone.change(usec: 0)
252
+ return unless timestamp.year.between?(MINIMUM_YEAR, MAXIMUM_YEAR)
253
+
254
+ timestamp
255
+ rescue ArgumentError, RangeError
256
+ nil
257
+ end
258
+
259
+ def repeated_parameter_keys(query_string)
260
+ return [] if query_string.blank?
261
+
262
+ keys = URI.decode_www_form(query_string).filter_map do |key, _value|
263
+ key if INPUT_PARAMETER_KEYS.include?(key)
264
+ end
265
+ keys.tally.select { |_key, count| count > 1 }.keys
266
+ rescue ArgumentError
267
+ INPUT_PARAMETER_KEYS
268
+ end
269
+
270
+ def query_attribute(name, value, type)
271
+ ActiveRecord::Relation::QueryAttribute.new(name, value, type)
272
+ end
273
+
274
+ def local_value(time)
275
+ time.in_time_zone.strftime("%Y-%m-%dT%H:%M:%S")
276
+ end
277
+
278
+ def parse_local_time(parameter_key, match)
279
+ return unless @local_time_zone
280
+
281
+ components = match.captures
282
+ components[-1] ||= "0"
283
+ numeric_components = components.map { |component| Integer(component, 10) }
284
+ local_time = Time.utc(*numeric_components)
285
+ normalized_value = format("%04d-%02d-%02dT%02d:%02d:%02d", *numeric_components)
286
+ return unless local_time.strftime("%Y-%m-%dT%H:%M:%S") == normalized_value
287
+
288
+ periods = @local_time_zone.tzinfo.periods_for_local(local_time)
289
+ return if periods.empty?
290
+
291
+ # A local input cannot carry an offset. Include both repeated fall-back occurrences
292
+ # by resolving starts to the earlier instant and ends to the later instant.
293
+ instants = periods.map do |period|
294
+ (local_time - period.utc_total_offset).in_time_zone
295
+ end
296
+ parameter_key == "chart_end" ? instants.max : instants.min
297
+ end
298
+
299
+ def local_time_zone
300
+ return if @repeated_parameter_keys.include?(TIME_ZONE_PARAMETER_KEY)
301
+
302
+ value = @params[TIME_ZONE_PARAMETER_KEY] || @params[TIME_ZONE_PARAMETER_KEY.to_sym]
303
+ return Time.zone if value.nil?
304
+ return unless value.is_a?(String) && value.bytesize <= MAXIMUM_TIME_ZONE_LENGTH
305
+ return unless /\A[A-Za-z0-9._+-]+(?:\/[A-Za-z0-9._+-]+)*\z/.match?(value)
306
+
307
+ tzinfo = TZInfo::Timezone.get(value)
308
+ ActiveSupport::TimeZone.create(value, nil, tzinfo)
309
+ rescue TZInfo::InvalidTimezoneIdentifier
310
+ nil
311
+ end
312
+
313
+ def range_label(time)
314
+ local_time = time.in_time_zone
315
+ format = label_style == "date_time_year" ? "%b %-d, %Y %H:%M:%S" : "%b %-d, %H:%M:%S"
316
+ label = local_time.strftime(format)
317
+
318
+ backward_clock_transition? ? "#{label} #{local_time.formatted_offset}" : label
319
+ end
320
+
321
+ # Intentionally checks Time.zone (the server-rendered zone), not @local_time_zone: this backs
322
+ # the initial server-rendered label, before any browser-zone enhancement. The client mirrors
323
+ # this same concept for its own currently-displayed zone in
324
+ # performance_range_controller.js#crossesBackwardClockTransition — keep both in sync in spirit
325
+ # (elapsed exact time vs. elapsed civil time) but each must use its own zone, not the other's.
326
+ def backward_clock_transition?
327
+ @_backward_clock_transition ||= end_time.in_time_zone.utc_offset < start_time.in_time_zone.utc_offset
328
+ end
329
+
330
+ def resolve
331
+ range_key = preset_key
332
+
333
+ if (times = custom_times)
334
+ @start_time, @end_time = times
335
+
336
+ # Do not attach a preset identity to tampered bounds with a different elapsed duration.
337
+ if range_key && end_time - start_time == OPTIONS.fetch(range_key).fetch(:duration)
338
+ @key = range_key
339
+ @canonical_params = navigation_params
340
+ options = OPTIONS.fetch(key)
341
+ else
342
+ @key = nil
343
+ @canonical_params = custom_params
344
+ @interval_seconds = custom_interval_seconds
345
+ options = custom_label_options
346
+ end
347
+ else
348
+ @key = range_key || DEFAULT_KEY
349
+ options = OPTIONS.fetch(key)
350
+ @end_time = current_end_time
351
+ @start_time = end_time - options.fetch(:duration)
352
+ @canonical_params = range_key ? { "chart_range" => key } : {}
353
+ end
354
+
355
+ @interval_seconds ||= options.fetch(:interval_seconds)
356
+ @label_format = options.fetch(:label_format)
357
+ @label_style = options.fetch(:label_style)
358
+ end
359
+
360
+ def preset_key
361
+ return if @repeated_parameter_keys.include?("chart_range")
362
+
363
+ value = @params[:chart_range]
364
+ value if value.is_a?(String) && OPTIONS.key?(value)
365
+ end
366
+
367
+ def series_end_time
368
+ align_time(end_time - Rational(1, 1_000_000))
369
+ end
370
+
371
+ def series_start_time
372
+ align_time(start_time)
373
+ end
374
+
375
+ def current_end_time
376
+ current_time = Time.current
377
+ current_time.usec.zero? ? current_time : current_time.ceil
378
+ end
379
+ end
380
+ end