good_job 4.20.0 → 4.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +63 -6
- data/app/charts/good_job/latency_metric/base.rb +21 -0
- data/app/charts/good_job/latency_metric/execution.rb +26 -0
- data/app/charts/good_job/latency_metric/queue.rb +22 -0
- data/app/charts/good_job/latency_metric/total.rb +22 -0
- data/app/charts/good_job/latency_metric.rb +42 -0
- data/app/charts/good_job/performance_index_chart.rb +10 -8
- data/app/charts/good_job/performance_show_chart.rb +12 -4
- data/app/controllers/good_job/performance_controller.rb +12 -18
- data/app/frontend/good_job/style.css +5 -0
- data/app/models/concerns/good_job/advisory_lockable.rb +13 -0
- data/app/models/good_job/batch.rb +2 -4
- data/app/models/good_job/concurrency_claim.rb +162 -0
- data/app/models/good_job/cron_entry.rb +29 -12
- data/app/models/good_job/job.rb +21 -0
- data/app/models/good_job/process.rb +11 -8
- data/app/views/good_job/cron_entries/index.html.erb +1 -1
- data/app/views/good_job/performance/_latency.erb +6 -0
- data/app/views/good_job/performance/_metric.erb +8 -0
- data/app/views/good_job/performance/_range.erb +2 -2
- data/app/views/good_job/performance/index.html.erb +39 -49
- data/app/views/good_job/performance/show.html.erb +3 -1
- data/app/views/good_job/shared/_chart_container.erb +3 -1
- data/config/locales/az.yml +20 -4
- data/config/locales/de.yml +20 -4
- data/config/locales/en.yml +20 -4
- data/config/locales/es.yml +20 -4
- data/config/locales/fr.yml +20 -4
- data/config/locales/it.yml +20 -4
- data/config/locales/ja.yml +20 -4
- data/config/locales/ko.yml +20 -4
- data/config/locales/nl.yml +20 -4
- data/config/locales/pt-BR.yml +20 -4
- data/config/locales/ru.yml +36 -20
- data/config/locales/tr.yml +20 -4
- data/config/locales/uk.yml +34 -18
- data/config/locales/zh-CN.yml +20 -4
- data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +14 -0
- data/lib/generators/good_job/templates/update/migrations/21_create_good_job_concurrency_claims.rb.erb +27 -0
- data/lib/good_job/active_job_extensions/concurrency.rb +128 -42
- data/lib/good_job/active_job_extensions/labels.rb +4 -1
- data/lib/good_job/bulk.rb +1 -2
- data/lib/good_job/capsule_tracker.rb +84 -55
- data/lib/good_job/cron_manager.rb +1 -0
- data/lib/good_job/notifier/process_heartbeat.rb +30 -6
- data/lib/good_job/version.rb +1 -1
- data/lib/good_job.rb +4 -12
- metadata +24 -2
- data/lib/good_job/overridable_connection.rb +0 -38
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 510354d3834b40133fff5b01cda0ac035f58b327317098467a305ac2ca5372d4
|
|
4
|
+
data.tar.gz: 0bee2064ac6fc2f6d89c69e22ed3639d07341120a4dfecb0d77e025e94455148
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae50900ba6a6db18dc13112a3999a12fc215e1f0f856e761b253fbfa02689ded6a01e3c2bb7aaf776465e822fb7b0ca1d2075dff9c784fc14e62a4ebbec82dcb
|
|
7
|
+
data.tar.gz: 5b939f61fa88f4dbdfed4ef48bc7ea0d0702e09b01c8e7ca7008e2e9cf4c112568e0a299197eb7985da9fb05e89a81d5d332a4bedbc98aac7dad6268d9f91579
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
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
|
+
|
|
3
42
|
## [v4.20.0](https://github.com/bensheldon/good_job/tree/v4.20.0) (2026-10-03)
|
|
4
43
|
|
|
5
44
|
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.4...v4.20.0)
|
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)
|
|
@@ -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
|
|
565
|
-
# that were enqueued with this label in
|
|
566
|
-
|
|
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
|
|
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: [
|
|
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
|
-
-
|
|
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,
|
|
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
|
-
|
|
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['
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
(hash[job_class] ||= []) <<
|
|
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:
|
|
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
|
-
|
|
28
|
-
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
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
@
|
|
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
|
|
@@ -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;
|
|
@@ -207,6 +207,7 @@ module GoodJob
|
|
|
207
207
|
end
|
|
208
208
|
|
|
209
209
|
ADVISORY_LOCK_COUNTS = AdvisoryLockCounter.new
|
|
210
|
+
private_constant :ADVISORY_LOCK_COUNTS
|
|
210
211
|
AREL_TABLE_NEW_KWARGS = Arel::Table.instance_method(:initialize).parameters.any? { |type, name| type == :key && name == :name }
|
|
211
212
|
|
|
212
213
|
included do
|
|
@@ -735,6 +736,18 @@ module GoodJob
|
|
|
735
736
|
self.class.owns_advisory_lock_key?(key)
|
|
736
737
|
end
|
|
737
738
|
|
|
739
|
+
# Tests whether +connection+ currently holds the advisory lock for this record.
|
|
740
|
+
# Checks both that the connection is still active and that the local bookkeeping
|
|
741
|
+
# records a lock acquired on it. Does not query the database.
|
|
742
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter, nil]
|
|
743
|
+
# @param key [String, Symbol] Advisory lock key to check
|
|
744
|
+
# @return [Boolean]
|
|
745
|
+
def advisory_lock_active_on?(connection, key: lockable_key)
|
|
746
|
+
return false unless connection&.active?
|
|
747
|
+
|
|
748
|
+
ADVISORY_LOCK_COUNTS.counts_for(connection, key).first.positive?
|
|
749
|
+
end
|
|
750
|
+
|
|
738
751
|
# Releases all advisory locks on the record that are held by the current
|
|
739
752
|
# database session.
|
|
740
753
|
# @param key [String, Symbol] Key to lock against
|
|
@@ -390,10 +390,8 @@ module GoodJob
|
|
|
390
390
|
# Jobs with concurrency limits must be enqueued individually so the
|
|
391
391
|
# before_enqueue concurrency check runs. Mirrors Bulk::Buffer#enqueue
|
|
392
392
|
# partitioning (bulk.rb:95-98).
|
|
393
|
-
if active_job.respond_to?(:
|
|
394
|
-
active_job.
|
|
395
|
-
(active_job.class.good_job_concurrency_config[:enqueue_limit] ||
|
|
396
|
-
active_job.class.good_job_concurrency_config[:total_limit])
|
|
393
|
+
if active_job.respond_to?(:good_job_enqueue_concurrency_controlled?) &&
|
|
394
|
+
active_job.good_job_enqueue_concurrency_controlled?
|
|
397
395
|
unbulkable << { batch: batch, active_job: active_job }
|
|
398
396
|
else
|
|
399
397
|
good_job = Job.build_for_enqueue(active_job)
|