good_job 4.19.3 → 4.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/README.md +64 -7
  4. data/app/charts/good_job/latency_metric/base.rb +21 -0
  5. data/app/charts/good_job/latency_metric/execution.rb +26 -0
  6. data/app/charts/good_job/latency_metric/queue.rb +22 -0
  7. data/app/charts/good_job/latency_metric/total.rb +22 -0
  8. data/app/charts/good_job/latency_metric.rb +42 -0
  9. data/app/charts/good_job/performance_index_chart.rb +10 -8
  10. data/app/charts/good_job/performance_show_chart.rb +12 -4
  11. data/app/controllers/good_job/performance_controller.rb +12 -18
  12. data/app/filters/good_job/base_filter.rb +12 -10
  13. data/app/filters/good_job/batches_filter.rb +12 -2
  14. data/app/frontend/good_job/modules/async_values_controller.js +7 -0
  15. data/app/frontend/good_job/modules/live_poll_controller.js +16 -1
  16. data/app/frontend/good_job/style.css +5 -0
  17. data/app/helpers/good_job/application_helper.rb +18 -0
  18. data/app/models/concerns/good_job/advisory_lockable.rb +13 -0
  19. data/app/models/concerns/good_job/filterable.rb +19 -6
  20. data/app/models/good_job/batch.rb +2 -4
  21. data/app/models/good_job/concurrency_claim.rb +162 -0
  22. data/app/models/good_job/cron_entry.rb +32 -13
  23. data/app/models/good_job/job.rb +47 -7
  24. data/app/models/good_job/process.rb +11 -8
  25. data/app/views/good_job/batches/_jobs.erb +1 -1
  26. data/app/views/good_job/batches/_table.erb +1 -1
  27. data/app/views/good_job/batches/show.html.erb +1 -1
  28. data/app/views/good_job/cron_entries/index.html.erb +1 -1
  29. data/app/views/good_job/jobs/_executions.erb +1 -1
  30. data/app/views/good_job/jobs/_table.erb +1 -1
  31. data/app/views/good_job/jobs/show.html.erb +2 -2
  32. data/app/views/good_job/performance/_latency.erb +6 -0
  33. data/app/views/good_job/performance/_metric.erb +8 -0
  34. data/app/views/good_job/performance/_range.erb +2 -2
  35. data/app/views/good_job/performance/index.html.erb +39 -49
  36. data/app/views/good_job/performance/show.html.erb +3 -1
  37. data/app/views/good_job/shared/_chart_container.erb +3 -1
  38. data/config/brakeman.ignore +38 -32
  39. data/config/locales/az.yml +20 -4
  40. data/config/locales/de.yml +20 -4
  41. data/config/locales/en.yml +20 -4
  42. data/config/locales/es.yml +20 -4
  43. data/config/locales/fr.yml +20 -4
  44. data/config/locales/it.yml +20 -4
  45. data/config/locales/ja.yml +20 -4
  46. data/config/locales/ko.yml +20 -4
  47. data/config/locales/nl.yml +20 -4
  48. data/config/locales/pt-BR.yml +20 -4
  49. data/config/locales/ru.yml +36 -20
  50. data/config/locales/tr.yml +20 -4
  51. data/config/locales/uk.yml +34 -18
  52. data/config/locales/zh-CN.yml +20 -4
  53. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +34 -6
  54. data/lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb.erb +11 -6
  55. data/lib/generators/good_job/templates/update/migrations/16_add_index_good_jobs_dequeue_by_queue.rb.erb +21 -0
  56. data/lib/generators/good_job/templates/update/migrations/17_add_index_good_job_executions_on_scheduled_at.rb.erb +12 -0
  57. data/lib/generators/good_job/templates/update/migrations/18_add_batch_query_indexes.rb.erb +28 -0
  58. data/lib/generators/good_job/templates/update/migrations/19_remove_redundant_good_jobs_indexes.rb.erb +55 -0
  59. data/lib/generators/good_job/templates/update/migrations/20_reorder_good_jobs_cron_index.rb.erb +51 -0
  60. data/lib/generators/good_job/templates/update/migrations/21_create_good_job_concurrency_claims.rb.erb +27 -0
  61. data/lib/good_job/active_job_extensions/concurrency.rb +128 -42
  62. data/lib/good_job/active_job_extensions/labels.rb +4 -1
  63. data/lib/good_job/bulk.rb +1 -2
  64. data/lib/good_job/capsule.rb +1 -1
  65. data/lib/good_job/capsule_tracker.rb +84 -55
  66. data/lib/good_job/cli.rb +32 -1
  67. data/lib/good_job/configuration.rb +92 -3
  68. data/lib/good_job/cron_manager.rb +31 -7
  69. data/lib/good_job/lifecycle_hooks.rb +83 -0
  70. data/lib/good_job/log_subscriber.rb +78 -0
  71. data/lib/good_job/multi_scheduler.rb +2 -1
  72. data/lib/good_job/notifier/process_heartbeat.rb +30 -6
  73. data/lib/good_job/notifier.rb +5 -3
  74. data/lib/good_job/probe_server/cluster_healthcheck_middleware.rb +32 -0
  75. data/lib/good_job/probe_server/simple_handler.rb +14 -9
  76. data/lib/good_job/probe_server/webrick_handler.rb +12 -0
  77. data/lib/good_job/probe_server.rb +18 -0
  78. data/lib/good_job/shared_executor.rb +3 -1
  79. data/lib/good_job/subprocess.rb +135 -0
  80. data/lib/good_job/supervisor.rb +536 -0
  81. data/lib/good_job/systemd_service.rb +12 -1
  82. data/lib/good_job/version.rb +1 -1
  83. data/lib/good_job.rb +17 -3
  84. metadata +33 -2
  85. data/lib/good_job/overridable_connection.rb +0 -38
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4d6ba03812a521f55ab70bb95caafb96aba83c049036e9f8cc5d7489f3f8dcc
4
- data.tar.gz: 3898461e6eccf9e8c6106b69a41123b82ff2e5a4697205464c60336304b4a5ef
3
+ metadata.gz: 510354d3834b40133fff5b01cda0ac035f58b327317098467a305ac2ca5372d4
4
+ data.tar.gz: 0bee2064ac6fc2f6d89c69e22ed3639d07341120a4dfecb0d77e025e94455148
5
5
  SHA512:
6
- metadata.gz: c924ff049f2b6153f1e7a98f7ff5eb35f68dfecd38079e99d84f535da56628eef67ae453a2b6255759d037f563249f02e4c442eae4a5196fdbf7ca2655f62e39
7
- data.tar.gz: a0724d9b3ded29985d5e16958d38ba3cddc90f66d800a0c13e68581f455a338263987ab167e2a8010289786d9917585f1b1627c5657696e84cfd8b90e96f3d9b
6
+ metadata.gz: ae50900ba6a6db18dc13112a3999a12fc215e1f0f856e761b253fbfa02689ded6a01e3c2bb7aaf776465e822fb7b0ca1d2075dff9c784fc14e62a4ebbec82dcb
7
+ data.tar.gz: 5b939f61fa88f4dbdfed4ef48bc7ea0d0702e09b01c8e7ca7008e2e9cf4c112568e0a299197eb7985da9fb05e89a81d5d332a4bedbc98aac7dad6268d9f91579
data/CHANGELOG.md CHANGED
@@ -1,5 +1,92 @@
1
1
  # Changelog
2
2
 
3
+ ## [v4.21.1](https://github.com/bensheldon/good_job/tree/v4.21.1) (2026-10-07)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.21.0...v4.21.1)
6
+
7
+ **Fixed bugs:**
8
+
9
+ - Fix crash on natural language cron schedules \(v4.21.0 regression\) [\#1853](https://github.com/bensheldon/good_job/pull/1853) ([bensheldon](https://github.com/bensheldon))
10
+
11
+ **Closed issues:**
12
+
13
+ - v4.21.0 regression: undefined method 'next\_time' for an instance of EtOrbi::EoTime [\#1852](https://github.com/bensheldon/good_job/issues/1852)
14
+
15
+ ## [v4.21.0](https://github.com/bensheldon/good_job/tree/v4.21.0) (2026-10-06)
16
+
17
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.20.0...v4.21.0)
18
+
19
+ **Implemented enhancements:**
20
+
21
+ - Enqueue jobs with concurrency rules individually in bulk/batch, and support Lambda class-level labels [\#1851](https://github.com/bensheldon/good_job/pull/1851) ([bensheldon](https://github.com/bensheldon))
22
+
23
+ **Closed issues:**
24
+
25
+ - perform\_limit: 1 permits two same-label jobs to execute concurrently [\#1843](https://github.com/bensheldon/good_job/issues/1843)
26
+
27
+ **Merged pull requests:**
28
+
29
+ - Fix: Run the enqueue concurrency check always after the job's other enqueue callbacks [\#1850](https://github.com/bensheldon/good_job/pull/1850) ([bensheldon](https://github.com/bensheldon))
30
+ - Add updated\_at to good\_job\_concurrency\_claims [\#1849](https://github.com/bensheldon/good_job/pull/1849) ([bensheldon](https://github.com/bensheldon))
31
+ - Fix Russian/Ukrainian "one" plurals showing "1" for 21, 31, … and Russian apply\_to\_all text [\#1848](https://github.com/bensheldon/good_job/pull/1848) ([owgreen-dev](https://github.com/owgreen-dev))
32
+ - Deprecate combining label: and key: in concurrency rules [\#1847](https://github.com/bensheldon/good_job/pull/1847) ([bensheldon](https://github.com/bensheldon))
33
+ - Run specs in parallel with parallel\_tests [\#1845](https://github.com/bensheldon/good_job/pull/1845) ([bensheldon](https://github.com/bensheldon))
34
+ - Fix perform\_limit race and promote waiting jobs when a concurrency claim is released [\#1844](https://github.com/bensheldon/good_job/pull/1844) ([bensheldon](https://github.com/bensheldon))
35
+ - Fix Ukrainian x\_minutes "few" form showing "1" for every count [\#1842](https://github.com/bensheldon/good_job/pull/1842) ([owgreen-dev](https://github.com/owgreen-dev))
36
+ - Fix flaky CronManager graceful restart spec at minute boundary [\#1841](https://github.com/bensheldon/good_job/pull/1841) ([bensheldon](https://github.com/bensheldon))
37
+ - Validate cron entry keys and schedules instead of raising [\#1832](https://github.com/bensheldon/good_job/pull/1832) ([shiaho777](https://github.com/shiaho777))
38
+ - Fix concurrency label matching and document dynamic labels [\#1827](https://github.com/bensheldon/good_job/pull/1827) ([shiaho777](https://github.com/shiaho777))
39
+ - Add queue and total latency to the Performance dashboard [\#1812](https://github.com/bensheldon/good_job/pull/1812) ([dennispaagman](https://github.com/dennispaagman))
40
+ - Remove connection overrides from process heartbeats [\#1763](https://github.com/bensheldon/good_job/pull/1763) ([bensheldon](https://github.com/bensheldon))
41
+
42
+ ## [v4.20.0](https://github.com/bensheldon/good_job/tree/v4.20.0) (2026-10-03)
43
+
44
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.4...v4.20.0)
45
+
46
+ **Implemented enhancements:**
47
+
48
+ - Match identifier-like strings in dashboard search [\#1831](https://github.com/bensheldon/good_job/pull/1831) ([shiaho777](https://github.com/shiaho777))
49
+ - Add a queue-scoped index for the dequeue candidate query [\#1810](https://github.com/bensheldon/good_job/pull/1810) ([a-abdellatif98](https://github.com/a-abdellatif98))
50
+ - Add cluster mode [\#1803](https://github.com/bensheldon/good_job/pull/1803) ([bensheldon](https://github.com/bensheldon))
51
+
52
+ **Fixed bugs:**
53
+
54
+ - \[dashboard\] Mismatch between page header vs graph headers [\#1601](https://github.com/bensheldon/good_job/issues/1601)
55
+ - Add dashboard and batch indexes, remove redundant indexes, and reorder the cron index [\#1840](https://github.com/bensheldon/good_job/pull/1840) ([bensheldon](https://github.com/bensheldon))
56
+ - Detect Puma worker boot hooks and Puma 8 requests in `in_webserver?` [\#1836](https://github.com/bensheldon/good_job/pull/1836) ([amanfredi](https://github.com/amanfredi))
57
+ - Keep both SharedExecutor threads available on JRuby [\#1835](https://github.com/bensheldon/good_job/pull/1835) ([amanfredi](https://github.com/amanfredi))
58
+ - Pass `cron_graceful_restart_period` to the CronManager so it takes effect [\#1833](https://github.com/bensheldon/good_job/pull/1833) ([amanfredi](https://github.com/amanfredi))
59
+ - Wrap Notifier's NOTIFY recipient dispatch in the Rails executor [\#1830](https://github.com/bensheldon/good_job/pull/1830) ([shiaho777](https://github.com/shiaho777))
60
+ - Preserve active backtrace tabs across live poll refreshes [\#1824](https://github.com/bensheldon/good_job/pull/1824) ([shiaho777](https://github.com/shiaho777))
61
+ - Refetch async navbar values after a live poll refresh [\#1823](https://github.com/bensheldon/good_job/pull/1823) ([shiaho777](https://github.com/shiaho777))
62
+ - Truncate long strings when displaying job params on the dashboard [\#1822](https://github.com/bensheldon/good_job/pull/1822) ([shiaho777](https://github.com/shiaho777))
63
+ - Bound all search\_text documents under the tsvector size limit [\#1820](https://github.com/bensheldon/good_job/pull/1820) ([shiaho777](https://github.com/shiaho777))
64
+ - Finish a job's unfinished executions when it is discarded [\#1819](https://github.com/bensheldon/good_job/pull/1819) ([shiaho777](https://github.com/shiaho777))
65
+
66
+ **Closed issues:**
67
+
68
+ - Dequeue candidate indexes don't cover queue-filtered workers — one queue's backlog degrades every other queue's claim query [\#1790](https://github.com/bensheldon/good_job/issues/1790)
69
+ - PG: ProgramLimitExceeded during search when a job has a huge error message [\#1785](https://github.com/bensheldon/good_job/issues/1785)
70
+ - Very large `serialized_params` caused dashboard timeout on Heroku [\#1629](https://github.com/bensheldon/good_job/issues/1629)
71
+ - \[dashboard\] Viewing a job & live poll resets the Full Trace vs Application Trace [\#1606](https://github.com/bensheldon/good_job/issues/1606)
72
+ - \[GoodJob\] Notifier errored: NameError: uninitialized constant GoodJob::Job in develpment [\#1571](https://github.com/bensheldon/good_job/issues/1571)
73
+ - Bug: calling GoodJob.restart in Puma on\_worker\_boot still starts GoodJob regardless of it being in async mode or not [\#1531](https://github.com/bensheldon/good_job/issues/1531)
74
+ - Discarding a job via the dashboard should also update the discrete\_execution [\#1093](https://github.com/bensheldon/good_job/issues/1093)
75
+ - A copy of GoodJob::Execution has been removed from the module tree but is still active! [\#954](https://github.com/bensheldon/good_job/issues/954)
76
+
77
+ **Merged pull requests:**
78
+
79
+ - Wait for Turbo navigation before reading the URL in performance specs [\#1838](https://github.com/bensheldon/good_job/pull/1838) ([amanfredi](https://github.com/amanfredi))
80
+ - Bump github/codeql-action from 4.37.9 to 4.38.2 [\#1837](https://github.com/bensheldon/good_job/pull/1837) ([dependabot[bot]](https://github.com/apps/dependabot))
81
+
82
+ ## [v4.19.4](https://github.com/bensheldon/good_job/tree/v4.19.4) (2026-10-02)
83
+
84
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.3...v4.19.4)
85
+
86
+ **Merged pull requests:**
87
+
88
+ - fix: Render batch details with JSON 3 [\#1839](https://github.com/bensheldon/good_job/pull/1839) ([luizkowalski](https://github.com/luizkowalski))
89
+
3
90
  ## [v4.19.3](https://github.com/bensheldon/good_job/tree/v4.19.3) (2026-09-21)
4
91
 
5
92
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.2...v4.19.3)
data/README.md CHANGED
@@ -47,6 +47,7 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
47
47
  - [Extending dashboard views](#extending-dashboard-views)
48
48
  - [Job priority](#job-priority)
49
49
  - [Concurrency controls](#concurrency-controls)
50
+ - [Dynamic labels](#dynamic-labels)
50
51
  - [How concurrency controls work](#how-concurrency-controls-work)
51
52
  - [Cron-style repeating/recurring jobs](#cron-style-repeatingrecurring-jobs)
52
53
  - [Bulk enqueue](#bulk-enqueue)
@@ -308,7 +309,7 @@ Available configuration options are:
308
309
  - `max_cache` (integer) sets the maximum number of scheduled jobs that will be stored in memory to reduce execution latency when also polling for scheduled jobs. Caching 10,000 scheduled jobs uses approximately 20MB of memory. You can also set this with the environment variable `GOOD_JOB_MAX_CACHE`.
309
310
  - `shutdown_timeout` (integer) number of seconds to wait for jobs to finish when shutting down before stopping the thread. Defaults to forever: `-1`. You can also set this with the environment variable `GOOD_JOB_SHUTDOWN_TIMEOUT`.
310
311
  - `enable_cron` (boolean) whether to run cron process. Defaults to `false`. You can also set this with the environment variable `GOOD_JOB_ENABLE_CRON`.
311
- - `cron_graceful_restart_period` (integer) when restarting cron, attempt to re-enqueue jobs that would have been enqueued by cron within this time period (e.g. `1.minute`). This should match the expected downtime during deploys.
312
+ - `cron_graceful_restart_period` (integer seconds or `ActiveSupport::Duration`) when cron starts, attempt to re-enqueue jobs that would have been enqueued by cron within this time period (e.g. `1.minute`). This should match the expected downtime during deploys. Like enabling cron on multiple processes, this relies on cron-created job records being preserved to avoid re-enqueuing jobs that already ran, so it is ignored when `preserve_job_records` is `false` or `:on_unhandled_error`. It does not apply to a `cron` proc that returns a time, which already enqueues the runs it missed since its last job. Defaults to `nil` (disabled). You can also set this with the environment variable `GOOD_JOB_CRON_GRACEFUL_RESTART_PERIOD`.
312
313
  - `enable_listen_notify` (boolean) whether to enqueue and read jobs with Postgres LISTEN/NOTIFY. Defaults to `true`. You can also set this with the environment variable `GOOD_JOB_ENABLE_LISTEN_NOTIFY`.
313
314
  - `cron` (hash) cron configuration. Defaults to `{}`. You can also set this as a JSON string with the environment variable `GOOD_JOB_CRON`
314
315
  - `cleanup_discarded_jobs` (boolean) whether to destroy discarded jobs when cleaning up preserved jobs using the `$ good_job cleanup_preserved_jobs` CLI command or calling `GoodJob.cleanup_preserved_jobs`. Defaults to `true`. Can also be set with the environment variable `GOOD_JOB_CLEANUP_DISCARDED_JOBS`.
@@ -561,9 +562,10 @@ class MyJob < ApplicationJob
561
562
  # exceeded rule short-circuits the rest.
562
563
  good_job_concurrency_rule(
563
564
  # A label that scopes this rule. Can be a static String or a Lambda/Proc
564
- # invoked in the context of the job instance. The rule only applies to jobs
565
- # that were enqueued with this label in `good_job_labels`.
566
- label: -> { arguments.first[:user_id] },
565
+ # invoked in the context of the job instance (see "Dynamic labels" below).
566
+ # The rule only applies to jobs that were enqueued with this label in
567
+ # `good_job_labels`.
568
+ label: "email",
567
569
 
568
570
  # Maximum number of unfinished jobs with this label to allow.
569
571
  # Can be an Integer or Lambda/Proc invoked in the context of the job.
@@ -595,7 +597,7 @@ class MyJob < ApplicationJob
595
597
  good_job_concurrency_rule(...)
596
598
  good_job_concurrency_rule(...)
597
599
 
598
- def perform(user_id:)
600
+ def perform
599
601
  # do work
600
602
  end
601
603
  end
@@ -604,16 +606,71 @@ end
604
606
  Jobs must be enqueued with the matching label for rules to take effect:
605
607
 
606
608
  ```ruby
607
- MyJob.set(good_job_labels: [current_user.id]).perform_later(user_id: current_user.id)
609
+ MyJob.set(good_job_labels: ["email"]).perform_later
608
610
  ```
609
611
 
612
+ Supplying both `label:` and `key:` to `good_job_concurrency_rule` is deprecated. For labelled rules, `key:` is ignored: jobs sharing the label use the same advisory lock for concurrency checks. Remove `key:` from these rules. The legacy `good_job_control_concurrency_with(key: ...)` interface remains supported.
613
+
614
+ #### Dynamic labels
615
+
616
+ A rule's `label:` can also be a Lambda/Proc that is invoked in the context of the job instance, for example to derive the label from job arguments. The lambda resolves the label to check against; the job's `good_job_labels` must still contain it for the rule to apply. Rule labels and job labels are converted to strings and stripped of surrounding whitespace when matched, consistent with how labels are stored.
617
+
618
+ To apply a dynamic label to every job of a class, set a Lambda/Proc in the class-level `good_job_labels`. It is invoked in the context of the job when the job is initialized, so the label is present for checks at both enqueue and perform time:
619
+
620
+ ```ruby
621
+ class MyJob < ApplicationJob
622
+ include GoodJob::ActiveJobExtensions::Concurrency
623
+
624
+ self.good_job_labels = [-> { "user-#{arguments.first[:user_id]}" }]
625
+
626
+ good_job_concurrency_rule(
627
+ label: -> { "user-#{arguments.first[:user_id]}" },
628
+ total_limit: 1
629
+ )
630
+
631
+ def perform(user_id:)
632
+ # do work
633
+ end
634
+ end
635
+ ```
636
+
637
+ Labels can also be applied in a `before_enqueue` callback. They are stored on the job record and checked by the rules:
638
+
639
+ ```ruby
640
+ class MyJob < ApplicationJob
641
+ include GoodJob::ActiveJobExtensions::Concurrency
642
+
643
+ before_enqueue do |job|
644
+ job.good_job_labels = [job.arguments.first[:user_id].to_s]
645
+ end
646
+
647
+ good_job_concurrency_rule(
648
+ label: -> { arguments.first[:user_id].to_s },
649
+ perform_limit: 1
650
+ )
651
+
652
+ def perform(user_id:)
653
+ # do work
654
+ end
655
+ end
656
+ ```
657
+
658
+ Rules are checked when a job is enqueued and again when it is performed. The enqueue-time check runs after the job's other `before_enqueue` and `around_enqueue` callbacks, regardless of where they are defined, so labels assigned in those callbacks are present for both checks. Labels can also be passed when enqueuing:
659
+
660
+ ```ruby
661
+ MyJob.set(good_job_labels: [user_id.to_s]).perform_later(user_id: user_id)
662
+ ```
663
+
664
+ Rules apply across job classes to jobs carrying the resolved label. `total_limit` counts unfinished jobs, `enqueue_limit` excludes claimed/performing jobs, and `perform_limit` counts running jobs. Throttles count enqueued jobs or executions within their time window, including finished ones.
665
+
610
666
  #### How concurrency controls work
611
667
 
612
668
  GoodJob's concurrency control strategy for `perform_limit` is "optimistic retry with an incremental backoff". The [code is readable](https://github.com/bensheldon/good_job/blob/main/lib/good_job/active_job_extensions/concurrency.rb).
613
669
 
614
670
  - "Optimistic" meaning that the implementation's performance trade-off assumes that collisions are atypical (e.g. two users enqueue the same job at the same time) rather than regular (e.g. the system enqueues thousands of colliding jobs at the same time). Depending on your concurrency requirements, you may also want to manage concurrency through the number of GoodJob threads and processes that are performing a given queue.
615
671
  - "Retry with an incremental backoff" means that when `perform_limit` is exceeded, the job will raise a `GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError` which is caught by a `retry_on` handler which re-schedules the job to execute in the near future with an incremental backoff.
616
- - First-in-first-out job execution order is not preserved when a job is retried with incremental back-off.
672
+ - When the `good_job_concurrency_claims` table has been migrated, a job that exceeded `perform_limit` waits on the limit's key, and when a job holding that key finishes, the longest-waiting job is re-scheduled to run immediately rather than waiting for its backoff. The incremental backoff remains as a fallback.
673
+ - First-in-first-out job execution order is not strictly preserved: a waiting job that is re-scheduled can be overtaken by a newly enqueued job, and jobs retried with incremental back-off run in backoff order.
617
674
  - For pessimistic usecases that collisions are expected, use number of threads/processes (e.g., `good_job --queues "serial:1;-serial:5"`) to control concurrency. It is also a good idea to use `perform_limit` as backstop.
618
675
 
619
676
  #### Legacy: `good_job_control_concurrency_with`
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob
4
+ module LatencyMetric
5
+ # A measurement the Performance charts can plot. Subclasses supply a SQL
6
+ # expression over +good_job_executions+, the aggregate that summarizes it per
7
+ # time bucket, and their titles.
8
+ class Base
9
+ def expression = self.class::EXPRESSION
10
+
11
+ def to_arel = Arel.sql(expression)
12
+
13
+ # nil plots an empty bucket as a gap; a summed metric overrides this with a true zero.
14
+ def empty_value = nil
15
+
16
+ def to_params = { PARAMETER_KEY => key.to_s }
17
+
18
+ def span_gaps? = empty_value.nil?
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob
4
+ module LatencyMetric
5
+ # How long jobs took to run. Summed across a bucket because total execution time
6
+ # is a measure of load.
7
+ class Execution < Base
8
+ EXPRESSION = "duration"
9
+
10
+ def key = :execution
11
+
12
+ def aggregate = "SUM"
13
+
14
+ def presence_sql = "duration IS NOT NULL"
15
+
16
+ def empty_value = 0
17
+
18
+ # The default metric stays out of navigation URLs.
19
+ def to_params = {}
20
+
21
+ def chart_title = I18n.t("good_job.performance.index.chart_title")
22
+
23
+ def histogram_title = I18n.t("good_job.performance.show.execution_chart_title")
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob
4
+ module LatencyMetric
5
+ # How long jobs waited between becoming eligible and actually starting, the same
6
+ # quantity as GoodJob::Execution#queue_latency. Averaged across a bucket because a
7
+ # summed wait would grow with volume alone.
8
+ class Queue < Base
9
+ EXPRESSION = "(created_at - scheduled_at)"
10
+
11
+ def key = :queue
12
+
13
+ def aggregate = "AVG"
14
+
15
+ def presence_sql = "scheduled_at IS NOT NULL"
16
+
17
+ def chart_title = I18n.t("good_job.performance.index.chart_title_queue")
18
+
19
+ def histogram_title = I18n.t("good_job.performance.show.queue_chart_title")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob
4
+ module LatencyMetric
5
+ # How long jobs took end to end: the wait before starting plus the run itself.
6
+ # Averaged across a bucket for the same reason as Queue.
7
+ class Total < Base
8
+ # NULL while an execution is still running, so the aggregates skip it rather than count zero.
9
+ EXPRESSION = "(#{GoodJob::LatencyMetric::Queue::EXPRESSION} + #{GoodJob::LatencyMetric::Execution::EXPRESSION})".freeze
10
+
11
+ def key = :total
12
+
13
+ def aggregate = "AVG"
14
+
15
+ def presence_sql = "scheduled_at IS NOT NULL AND duration IS NOT NULL"
16
+
17
+ def chart_title = I18n.t("good_job.performance.index.chart_title_total")
18
+
19
+ def histogram_title = I18n.t("good_job.performance.show.total_chart_title")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob
4
+ # The measurements the Performance charts plot and the index tables aggregate.
5
+ module LatencyMetric
6
+ PARAMETER_KEY = :chart
7
+
8
+ def self.options
9
+ [
10
+ GoodJob::LatencyMetric::Execution.new,
11
+ GoodJob::LatencyMetric::Queue.new,
12
+ GoodJob::LatencyMetric::Total.new,
13
+ ]
14
+ end
15
+
16
+ def self.default = options.first
17
+
18
+ def self.table_aggregates_sql
19
+ <<~SQL.squish
20
+ COUNT(DISTINCT active_job_id) AS jobs_count,
21
+ COUNT(*) AS executions_count,
22
+ AVG(#{GoodJob::LatencyMetric::Queue::EXPRESSION}) AS avg_queue,
23
+ MIN(#{GoodJob::LatencyMetric::Queue::EXPRESSION}) AS min_queue,
24
+ MAX(#{GoodJob::LatencyMetric::Queue::EXPRESSION}) AS max_queue,
25
+ AVG(#{GoodJob::LatencyMetric::Execution::EXPRESSION}) AS avg_execution,
26
+ MIN(#{GoodJob::LatencyMetric::Execution::EXPRESSION}) AS min_execution,
27
+ MAX(#{GoodJob::LatencyMetric::Execution::EXPRESSION}) AS max_execution,
28
+ AVG(#{GoodJob::LatencyMetric::Total::EXPRESSION}) AS avg_total,
29
+ MIN(#{GoodJob::LatencyMetric::Total::EXPRESSION}) AS min_total,
30
+ MAX(#{GoodJob::LatencyMetric::Total::EXPRESSION}) AS max_total
31
+ SQL
32
+ end
33
+
34
+ # Compare as strings: the value is whatever the query string held, so array- and
35
+ # hash-valued input has to fall through to the default rather than be coerced.
36
+ def self.from_params(params)
37
+ key = params[PARAMETER_KEY]
38
+
39
+ options.find { |metric| metric.key.to_s == key } || default
40
+ end
41
+ end
42
+ end
@@ -2,9 +2,10 @@
2
2
 
3
3
  module GoodJob
4
4
  class PerformanceIndexChart < BaseChart
5
- def initialize(range = GoodJob::PerformanceRange.new)
5
+ def initialize(range = GoodJob::PerformanceRange.new, metric = GoodJob::LatencyMetric.default)
6
6
  super()
7
7
  @range = range
8
+ @metric = metric
8
9
  end
9
10
 
10
11
  def data
@@ -14,7 +15,7 @@ module GoodJob
14
15
  start_expression: "range_parameters.series_start_time",
15
16
  interval_expression: "range_parameters.interval_seconds"
16
17
  )
17
- inner_sql = @range.apply(GoodJob::Execution).select(:job_class, :scheduled_at, :duration).to_sql
18
+ inner_sql = @range.apply(GoodJob::Execution).select(:job_class, :scheduled_at, @metric.to_arel.as("metric_value")).to_sql
18
19
 
19
20
  # Keep each bind placeholder single-use because JDBC binds every converted occurrence.
20
21
  sum_query = <<~SQL.squish
@@ -34,7 +35,7 @@ module GoodJob
34
35
  SELECT
35
36
  #{bucket_sql} AS scheduled_at,
36
37
  job_class,
37
- SUM(duration) AS sum
38
+ #{@metric.aggregate}(metric_value) AS value
38
39
  FROM (#{inner_sql}) executions
39
40
  CROSS JOIN range_parameters
40
41
  GROUP BY #{bucket_sql}, job_class
@@ -47,16 +48,16 @@ module GoodJob
47
48
 
48
49
  executions_data = GoodJob::Job.connection_pool.with_connection { |conn| conn.exec_query(GoodJob::Job.pg_or_jdbc_query(sum_query), "GoodJob Performance Chart", binds) }
49
50
 
50
- job_names = executions_data.reject { |d| d['sum'].nil? }.map { |d| d['job_class'] || BaseFilter::EMPTY }.uniq
51
+ job_names = executions_data.reject { |d| d['value'].nil? }.map { |d| d['job_class'] || BaseFilter::EMPTY }.uniq
51
52
  timestamp_values = []
52
53
  timestamps = []
53
54
  jobs_data = executions_data.to_a.group_by { |d| d['timestamp'] }.each_with_object({}) do |(timestamp, values), hash|
54
55
  timestamp_values << timestamp
55
56
  timestamps << @range.canonical_timestamp(timestamp)
56
57
  job_names.each do |job_class|
57
- sum = values.find { |d| d['job_class'] == job_class }&.[]('sum')
58
- duration = sum ? ActiveSupport::Duration.parse(sum).to_f : 0
59
- (hash[job_class] ||= []) << duration
58
+ value = values.find { |d| d['job_class'] == job_class }&.[]('value')
59
+ seconds = value ? ActiveSupport::Duration.parse(value).to_f : @metric.empty_value
60
+ (hash[job_class] ||= []) << seconds
60
61
  end
61
62
  end
62
63
  labels = @range.chart_timestamp_labels(timestamp_values)
@@ -72,6 +73,7 @@ module GoodJob
72
73
  data: data,
73
74
  backgroundColor: string_to_hsl(label),
74
75
  borderColor: string_to_hsl(label),
76
+ spanGaps: @metric.span_gaps?,
75
77
  }
76
78
  end,
77
79
  },
@@ -79,7 +81,7 @@ module GoodJob
79
81
  plugins: {
80
82
  title: {
81
83
  display: true,
82
- text: I18n.t("good_job.performance.index.chart_title"),
84
+ text: @metric.chart_title,
83
85
  },
84
86
  legend: {
85
87
  vertical: true,
@@ -12,26 +12,28 @@ module GoodJob
12
12
  10**8 # About 3 years
13
13
  ].freeze
14
14
 
15
- def initialize(job_class, range = GoodJob::PerformanceRange.new)
15
+ def initialize(job_class, range = GoodJob::PerformanceRange.new, metric = GoodJob::LatencyMetric.default)
16
16
  super()
17
17
  @job_class = job_class
18
18
  @range = range
19
+ @metric = metric
19
20
  end
20
21
 
21
22
  def data
22
23
  table_name = GoodJob::Execution.table_name
23
24
 
24
25
  interval_entries = BUCKET_INTERVALS.map { "interval '#{_1}'" }.join(",")
26
+ bucket_expression = "WIDTH_BUCKET(#{@metric.expression}, ARRAY[#{interval_entries}])"
25
27
  sum_query = <<~SQL.squish
26
28
  SELECT
27
- WIDTH_BUCKET(duration, ARRAY[#{interval_entries}]) as bucket_index,
28
- COUNT(WIDTH_BUCKET(duration, ARRAY[#{interval_entries}])) AS count
29
+ #{bucket_expression} as bucket_index,
30
+ COUNT(#{bucket_expression}) AS count
29
31
  FROM #{table_name} sources
30
32
  WHERE
31
33
  scheduled_at >= $1::timestamp
32
34
  AND scheduled_at < $2::timestamp
33
35
  AND job_class = $3
34
- AND duration IS NOT NULL
36
+ AND #{@metric.presence_sql}
35
37
  GROUP BY bucket_index
36
38
  SQL
37
39
 
@@ -60,6 +62,12 @@ module GoodJob
60
62
  }],
61
63
  },
62
64
  options: {
65
+ plugins: {
66
+ title: {
67
+ display: true,
68
+ text: @metric.histogram_title,
69
+ },
70
+ },
63
71
  scales: {
64
72
  y: {
65
73
  beginAtZero: true,
@@ -6,30 +6,20 @@ module GoodJob
6
6
 
7
7
  def index
8
8
  executions = @performance_range.apply(GoodJob::Execution)
9
+ aggregates = GoodJob::LatencyMetric.table_aggregates_sql
9
10
 
10
- @performances = executions.group(:job_class).select("
11
- job_class,
12
- COUNT(*) AS executions_count,
13
- AVG(duration) AS avg_duration,
14
- MIN(duration) AS min_duration,
15
- MAX(duration) AS max_duration
16
- ").order(:job_class)
17
-
18
- @queue_performances = executions.group(:queue_name).select("
19
- queue_name,
20
- COUNT(*) AS executions_count,
21
- AVG(duration) AS avg_duration,
22
- MIN(duration) AS min_duration,
23
- MAX(duration) AS max_duration
24
- ").order(:queue_name)
25
-
26
- @chart_data = GoodJob::PerformanceIndexChart.new(@performance_range).data
11
+ @performances = executions.group(:job_class).select("job_class, #{aggregates}").order(:job_class)
12
+ @queue_performances = executions.group(:queue_name).select("queue_name, #{aggregates}").order(:queue_name)
13
+
14
+ @chart_data = GoodJob::PerformanceIndexChart.new(@performance_range, @chart_metric).data
27
15
  end
28
16
 
29
17
  def show
30
18
  representative_job = GoodJob::Job.find_by!(job_class: request.path_parameters.fetch(:id))
31
19
  @job_class = representative_job.job_class
32
- @chart_data = GoodJob::PerformanceShowChart.new(@job_class, @performance_range).data
20
+ @charts = GoodJob::LatencyMetric.options.to_h do |metric|
21
+ [metric.key, GoodJob::PerformanceShowChart.new(@job_class, @performance_range, metric).data]
22
+ end
33
23
  end
34
24
 
35
25
  private
@@ -38,10 +28,14 @@ module GoodJob
38
28
  locale = request.query_parameters["locale"]
39
29
  @performance_range_context = locale.is_a?(String) ? { "locale" => locale } : {}
40
30
  @performance_range = GoodJob::PerformanceRange.new(params, query_string: request.query_string)
31
+ # Only the index chart is metric-switchable; show renders every metric.
32
+ @chart_metric = GoodJob::LatencyMetric.from_params(params)
33
+ @chart_metric_params = action_name == "index" ? @chart_metric.to_params : {}
41
34
  return if @performance_range.canonical_parameters?(request.query_parameters)
42
35
 
43
36
  canonical_query = @performance_range.to_params.symbolize_keys
44
37
  canonical_query[:locale] = @performance_range_context["locale"]
38
+ canonical_query.merge!(@chart_metric_params.symbolize_keys)
45
39
  canonical_path = if action_name == "show"
46
40
  performance_path(request.path_parameters.fetch(:id), canonical_query)
47
41
  else
@@ -13,15 +13,17 @@ module GoodJob
13
13
  end
14
14
 
15
15
  def records
16
- after_id = params[:after_id]
17
- after_at = params[:after_at].present? ? Time.zone.parse(params[:after_at]) : nil
18
- limit = params.fetch(:limit, DEFAULT_LIMIT)
19
-
20
- query_for_records.display_all(
21
- ordered_by: ordered_by,
22
- after_at: after_at,
23
- after_id: after_id
24
- ).limit(limit)
16
+ @_records ||= begin
17
+ after_id = params[:after_id]
18
+ after_at = params[:after_at].present? ? Time.zone.parse(params[:after_at]) : nil
19
+ limit = params.fetch(:limit, DEFAULT_LIMIT)
20
+
21
+ query_for_records.display_all(
22
+ ordered_by: ordered_by,
23
+ after_at: after_at,
24
+ after_id: after_id
25
+ ).limit(limit)
26
+ end
25
27
  end
26
28
 
27
29
  def last
@@ -76,7 +78,7 @@ module GoodJob
76
78
 
77
79
  def next_page_params
78
80
  order_column = ordered_by.first
79
- last_record = records.last
81
+ last_record = last
80
82
 
81
83
  {
82
84
  after_at: last_record&.send(order_column)&.iso8601(6),
@@ -2,16 +2,26 @@
2
2
 
3
3
  module GoodJob
4
4
  class BatchesFilter < BaseFilter
5
+ def job_count(batch)
6
+ job_counts.fetch(batch.id, 0)
7
+ end
8
+
5
9
  def filtered_query(_filtered_params = params)
6
10
  base_query
7
11
  end
8
12
 
9
13
  def query_for_records
10
- default_base_query
14
+ base_query
11
15
  end
12
16
 
13
17
  def default_base_query
14
- GoodJob::BatchRecord.includes(:jobs)
18
+ GoodJob::BatchRecord.all
19
+ end
20
+
21
+ private
22
+
23
+ def job_counts
24
+ @_job_counts ||= GoodJob::Job.where(batch_id: records.map(&:id)).group(:batch_id).count
15
25
  end
16
26
  end
17
27
  end
@@ -7,8 +7,15 @@ export default class extends Controller {
7
7
  }
8
8
  static targets = ["value"]
9
9
 
10
+ #onLivePollRefresh = () => this.#fetch();
11
+
10
12
  connect() {
11
13
  this.#fetch();
14
+ document.addEventListener("good_job:live-poll-refreshed", this.#onLivePollRefresh);
15
+ }
16
+
17
+ disconnect() {
18
+ document.removeEventListener("good_job:live-poll-refreshed", this.#onLivePollRefresh);
12
19
  }
13
20
 
14
21
  async #fetch() {
@@ -56,8 +56,23 @@ export default class extends Controller {
56
56
  const regionName = newElement.getAttribute('data-live-poll-region')
57
57
  const originalElement = document.querySelector(`[data-live-poll-region="${regionName}"]`)
58
58
  if (originalElement) {
59
- originalElement.replaceWith(newElement)
59
+ this.#restoreTabs(originalElement, newElement)
60
60
  }
61
61
  })
62
62
  }
63
+
64
+ // Re-select any Bootstrap tabs that were active before a region is replaced
65
+ #restoreTabs(originalElement, newElement) {
66
+ const activeTargets = [...originalElement.querySelectorAll('.nav-link.active[data-bs-toggle]')]
67
+ .map((tab) => tab.getAttribute('data-bs-target') || tab.getAttribute('href'))
68
+ originalElement.replaceWith(newElement)
69
+ activeTargets.forEach((target) => {
70
+ const newTab = newElement.querySelector(`[data-bs-target="${target}"], [href="${target}"]`)
71
+ if (newTab) {
72
+ bootstrap.Tab.getOrCreateInstance(newTab).show()
73
+ }
74
+ })
75
+
76
+ document.dispatchEvent(new CustomEvent("good_job:live-poll-refreshed"))
77
+ }
63
78
  }
@@ -88,6 +88,11 @@
88
88
  min-width: 14rem;
89
89
  }
90
90
 
91
+ /* Grid cells default to min-width:auto, so a wide header label would grow its column instead of wrapping. */
92
+ .performance-row > * {
93
+ min-width: 0;
94
+ }
95
+
91
96
  .performance-name {
92
97
  overflow-wrap: anywhere;
93
98
  white-space: normal;