good_job 4.20.0 → 4.21.0

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +63 -6
  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/frontend/good_job/style.css +5 -0
  13. data/app/models/concerns/good_job/advisory_lockable.rb +13 -0
  14. data/app/models/good_job/batch.rb +2 -4
  15. data/app/models/good_job/concurrency_claim.rb +162 -0
  16. data/app/models/good_job/cron_entry.rb +40 -12
  17. data/app/models/good_job/job.rb +21 -0
  18. data/app/models/good_job/process.rb +11 -8
  19. data/app/views/good_job/cron_entries/index.html.erb +1 -1
  20. data/app/views/good_job/performance/_latency.erb +6 -0
  21. data/app/views/good_job/performance/_metric.erb +8 -0
  22. data/app/views/good_job/performance/_range.erb +2 -2
  23. data/app/views/good_job/performance/index.html.erb +39 -49
  24. data/app/views/good_job/performance/show.html.erb +3 -1
  25. data/app/views/good_job/shared/_chart_container.erb +3 -1
  26. data/config/locales/az.yml +20 -4
  27. data/config/locales/de.yml +20 -4
  28. data/config/locales/en.yml +20 -4
  29. data/config/locales/es.yml +20 -4
  30. data/config/locales/fr.yml +20 -4
  31. data/config/locales/it.yml +20 -4
  32. data/config/locales/ja.yml +20 -4
  33. data/config/locales/ko.yml +20 -4
  34. data/config/locales/nl.yml +20 -4
  35. data/config/locales/pt-BR.yml +20 -4
  36. data/config/locales/ru.yml +36 -20
  37. data/config/locales/tr.yml +20 -4
  38. data/config/locales/uk.yml +34 -18
  39. data/config/locales/zh-CN.yml +20 -4
  40. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +14 -0
  41. data/lib/generators/good_job/templates/update/migrations/21_create_good_job_concurrency_claims.rb.erb +27 -0
  42. data/lib/good_job/active_job_extensions/concurrency.rb +128 -42
  43. data/lib/good_job/active_job_extensions/labels.rb +4 -1
  44. data/lib/good_job/bulk.rb +1 -2
  45. data/lib/good_job/capsule_tracker.rb +84 -55
  46. data/lib/good_job/cron_manager.rb +1 -0
  47. data/lib/good_job/notifier/process_heartbeat.rb +30 -6
  48. data/lib/good_job/version.rb +1 -1
  49. data/lib/good_job.rb +4 -12
  50. metadata +24 -2
  51. data/lib/good_job/overridable_connection.rb +0 -38
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96a4cd91def79d6e98d59849112ee43979d9dd518e252ca693d12c0e71003463
4
- data.tar.gz: 5d82aa51b9f049b7655f0e7243ede43df3029011fd5e1f62637fdf193ba2cec3
3
+ metadata.gz: 735c92664371a084ca36ef44ba7622fabdea3027abe19e64cfd5b39f58bac250
4
+ data.tar.gz: bd64cac0a29019fa695a54ef7f9d6f6a8ca7f90aa2ecfd0a0da2d59dec0d52bf
5
5
  SHA512:
6
- metadata.gz: 9301c67f9223407df170c9d8b9c93859d274f6affb36afb2fe948e81f1b704d7d8215510ad1491cf86f83fafecd5cd94a2394f8a793cf995b28f41a326d75086
7
- data.tar.gz: 4b6ae502d9030ef467e9c7579ec2da0ccde6ccf35176d7a7a50e1fee13c0463030b9d725c0cd916439cdf58c32c3cf2d0a7bb1bea5c5e790c23110c670c510cf
6
+ metadata.gz: 23355961d770066f2cba918976485a8557a13b3f714a0b636e63719f3600bb4ad3cc6ad0f78515225a3ff5896befaed5067d87e38efec365190f42e2d4013da0
7
+ data.tar.gz: 4f53fa0a1efe24d90d91fbf16f42e092e824ac1bff72b3117b2da794896af90c04a1f65aa292ea96346cecb3d5b4fb442c87eef6b186647c6182bacbd706e2c1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [v4.21.0](https://github.com/bensheldon/good_job/tree/v4.21.0) (2026-10-06)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.20.0...v4.21.0)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - 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))
10
+
11
+ **Closed issues:**
12
+
13
+ - perform\_limit: 1 permits two same-label jobs to execute concurrently [\#1843](https://github.com/bensheldon/good_job/issues/1843)
14
+
15
+ **Merged pull requests:**
16
+
17
+ - 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))
18
+ - Add updated\_at to good\_job\_concurrency\_claims [\#1849](https://github.com/bensheldon/good_job/pull/1849) ([bensheldon](https://github.com/bensheldon))
19
+ - 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))
20
+ - Deprecate combining label: and key: in concurrency rules [\#1847](https://github.com/bensheldon/good_job/pull/1847) ([bensheldon](https://github.com/bensheldon))
21
+ - Run specs in parallel with parallel\_tests [\#1845](https://github.com/bensheldon/good_job/pull/1845) ([bensheldon](https://github.com/bensheldon))
22
+ - 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))
23
+ - 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))
24
+ - Fix flaky CronManager graceful restart spec at minute boundary [\#1841](https://github.com/bensheldon/good_job/pull/1841) ([bensheldon](https://github.com/bensheldon))
25
+ - Validate cron entry keys and schedules instead of raising [\#1832](https://github.com/bensheldon/good_job/pull/1832) ([shiaho777](https://github.com/shiaho777))
26
+ - Fix concurrency label matching and document dynamic labels [\#1827](https://github.com/bensheldon/good_job/pull/1827) ([shiaho777](https://github.com/shiaho777))
27
+ - Add queue and total latency to the Performance dashboard [\#1812](https://github.com/bensheldon/good_job/pull/1812) ([dennispaagman](https://github.com/dennispaagman))
28
+ - Remove connection overrides from process heartbeats [\#1763](https://github.com/bensheldon/good_job/pull/1763) ([bensheldon](https://github.com/bensheldon))
29
+
3
30
  ## [v4.20.0](https://github.com/bensheldon/good_job/tree/v4.20.0) (2026-10-03)
4
31
 
5
32
  [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. 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
@@ -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?(:good_job_concurrency_key) &&
394
- active_job.good_job_concurrency_key.present? &&
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)