good_job 4.19.4 → 4.20.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +1 -1
  4. data/app/filters/good_job/base_filter.rb +12 -10
  5. data/app/filters/good_job/batches_filter.rb +12 -2
  6. data/app/frontend/good_job/modules/async_values_controller.js +7 -0
  7. data/app/frontend/good_job/modules/live_poll_controller.js +16 -1
  8. data/app/helpers/good_job/application_helper.rb +18 -0
  9. data/app/models/concerns/good_job/filterable.rb +19 -6
  10. data/app/models/good_job/cron_entry.rb +3 -1
  11. data/app/models/good_job/job.rb +26 -7
  12. data/app/views/good_job/batches/_jobs.erb +1 -1
  13. data/app/views/good_job/batches/_table.erb +1 -1
  14. data/app/views/good_job/jobs/_executions.erb +1 -1
  15. data/app/views/good_job/jobs/_table.erb +1 -1
  16. data/app/views/good_job/jobs/show.html.erb +2 -2
  17. data/config/brakeman.ignore +38 -32
  18. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +20 -6
  19. data/lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb.erb +11 -6
  20. data/lib/generators/good_job/templates/update/migrations/16_add_index_good_jobs_dequeue_by_queue.rb.erb +21 -0
  21. data/lib/generators/good_job/templates/update/migrations/17_add_index_good_job_executions_on_scheduled_at.rb.erb +12 -0
  22. data/lib/generators/good_job/templates/update/migrations/18_add_batch_query_indexes.rb.erb +28 -0
  23. data/lib/generators/good_job/templates/update/migrations/19_remove_redundant_good_jobs_indexes.rb.erb +55 -0
  24. data/lib/generators/good_job/templates/update/migrations/20_reorder_good_jobs_cron_index.rb.erb +51 -0
  25. data/lib/good_job/capsule.rb +1 -1
  26. data/lib/good_job/cli.rb +32 -1
  27. data/lib/good_job/configuration.rb +92 -3
  28. data/lib/good_job/cron_manager.rb +30 -7
  29. data/lib/good_job/lifecycle_hooks.rb +83 -0
  30. data/lib/good_job/log_subscriber.rb +78 -0
  31. data/lib/good_job/multi_scheduler.rb +2 -1
  32. data/lib/good_job/notifier.rb +5 -3
  33. data/lib/good_job/probe_server/cluster_healthcheck_middleware.rb +32 -0
  34. data/lib/good_job/probe_server/simple_handler.rb +14 -9
  35. data/lib/good_job/probe_server/webrick_handler.rb +12 -0
  36. data/lib/good_job/probe_server.rb +18 -0
  37. data/lib/good_job/shared_executor.rb +3 -1
  38. data/lib/good_job/subprocess.rb +135 -0
  39. data/lib/good_job/supervisor.rb +536 -0
  40. data/lib/good_job/systemd_service.rb +12 -1
  41. data/lib/good_job/version.rb +1 -1
  42. data/lib/good_job.rb +23 -1
  43. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02e6122e753d3039eccdd63032986cc9736ffcb972018b94ed0728db85935b18
4
- data.tar.gz: 4937737349d54e5c7c58ddf3f5c03c51f9384188a18cc38b307c93da990f136d
3
+ metadata.gz: 96a4cd91def79d6e98d59849112ee43979d9dd518e252ca693d12c0e71003463
4
+ data.tar.gz: 5d82aa51b9f049b7655f0e7243ede43df3029011fd5e1f62637fdf193ba2cec3
5
5
  SHA512:
6
- metadata.gz: a17d7eac17cfde7e5d1157cb3724c5ee86d9c8b13acf453408eb567df117872bd9846320427fdda333cb530971f89b50526642e7326fd98327f826c1067010fd
7
- data.tar.gz: 12d760ca409707c4d5521f0ba80d3db48cd54ce96c219b465db4d920d0aea87471830e3f98c17cacb32cde2f309a4149e945823293b5a464e7d1e9b440862785
6
+ metadata.gz: 9301c67f9223407df170c9d8b9c93859d274f6affb36afb2fe948e81f1b704d7d8215510ad1491cf86f83fafecd5cd94a2394f8a793cf995b28f41a326d75086
7
+ data.tar.gz: 4b6ae502d9030ef467e9c7579ec2da0ccde6ccf35176d7a7a50e1fee13c0463030b9d725c0cd916439cdf58c32c3cf2d0a7bb1bea5c5e790c23110c670c510cf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## [v4.20.0](https://github.com/bensheldon/good_job/tree/v4.20.0) (2026-10-03)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.4...v4.20.0)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - Match identifier-like strings in dashboard search [\#1831](https://github.com/bensheldon/good_job/pull/1831) ([shiaho777](https://github.com/shiaho777))
10
+ - 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))
11
+ - Add cluster mode [\#1803](https://github.com/bensheldon/good_job/pull/1803) ([bensheldon](https://github.com/bensheldon))
12
+
13
+ **Fixed bugs:**
14
+
15
+ - \[dashboard\] Mismatch between page header vs graph headers [\#1601](https://github.com/bensheldon/good_job/issues/1601)
16
+ - 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))
17
+ - 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))
18
+ - Keep both SharedExecutor threads available on JRuby [\#1835](https://github.com/bensheldon/good_job/pull/1835) ([amanfredi](https://github.com/amanfredi))
19
+ - 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))
20
+ - Wrap Notifier's NOTIFY recipient dispatch in the Rails executor [\#1830](https://github.com/bensheldon/good_job/pull/1830) ([shiaho777](https://github.com/shiaho777))
21
+ - Preserve active backtrace tabs across live poll refreshes [\#1824](https://github.com/bensheldon/good_job/pull/1824) ([shiaho777](https://github.com/shiaho777))
22
+ - Refetch async navbar values after a live poll refresh [\#1823](https://github.com/bensheldon/good_job/pull/1823) ([shiaho777](https://github.com/shiaho777))
23
+ - Truncate long strings when displaying job params on the dashboard [\#1822](https://github.com/bensheldon/good_job/pull/1822) ([shiaho777](https://github.com/shiaho777))
24
+ - Bound all search\_text documents under the tsvector size limit [\#1820](https://github.com/bensheldon/good_job/pull/1820) ([shiaho777](https://github.com/shiaho777))
25
+ - Finish a job's unfinished executions when it is discarded [\#1819](https://github.com/bensheldon/good_job/pull/1819) ([shiaho777](https://github.com/shiaho777))
26
+
27
+ **Closed issues:**
28
+
29
+ - 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)
30
+ - PG: ProgramLimitExceeded during search when a job has a huge error message [\#1785](https://github.com/bensheldon/good_job/issues/1785)
31
+ - Very large `serialized_params` caused dashboard timeout on Heroku [\#1629](https://github.com/bensheldon/good_job/issues/1629)
32
+ - \[dashboard\] Viewing a job & live poll resets the Full Trace vs Application Trace [\#1606](https://github.com/bensheldon/good_job/issues/1606)
33
+ - \[GoodJob\] Notifier errored: NameError: uninitialized constant GoodJob::Job in develpment [\#1571](https://github.com/bensheldon/good_job/issues/1571)
34
+ - 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)
35
+ - Discarding a job via the dashboard should also update the discrete\_execution [\#1093](https://github.com/bensheldon/good_job/issues/1093)
36
+ - 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)
37
+
38
+ **Merged pull requests:**
39
+
40
+ - 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))
41
+ - 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))
42
+
3
43
  ## [v4.19.4](https://github.com/bensheldon/good_job/tree/v4.19.4) (2026-10-02)
4
44
 
5
45
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.3...v4.19.4)
data/README.md CHANGED
@@ -308,7 +308,7 @@ Available configuration options are:
308
308
  - `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
309
  - `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
310
  - `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.
311
+ - `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
312
  - `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
313
  - `cron` (hash) cron configuration. Defaults to `{}`. You can also set this as a JSON string with the environment variable `GOOD_JOB_CRON`
314
314
  - `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`.
@@ -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
  }
@@ -7,6 +7,10 @@ module GoodJob
7
7
  # We can't rely on +config.action_controller.include_all_helpers = true+ in the host app.
8
8
  include IconsHelper
9
9
 
10
+ # Maximum length of an individual string rendered inside serialized params
11
+ # and arguments on the dashboard, so very large payloads cannot stall the page.
12
+ MAX_DISPLAY_STRING_LENGTH = 1_000
13
+
10
14
  def job_action_states
11
15
  {
12
16
  reschedule: %w[scheduled retried queued],
@@ -17,6 +21,20 @@ module GoodJob
17
21
  }
18
22
  end
19
23
 
24
+ # Truncates long strings within a JSON-like structure for dashboard display.
25
+ def truncate_display_value(value, limit: MAX_DISPLAY_STRING_LENGTH)
26
+ case value
27
+ when String
28
+ value.length > limit ? "#{value.first(limit)}… [#{value.length} characters total]" : value
29
+ when Array
30
+ value.map { |element| truncate_display_value(element, limit: limit) }
31
+ when Hash
32
+ value.transform_values { |element| truncate_display_value(element, limit: limit) }
33
+ else
34
+ value
35
+ end
36
+ end
37
+
20
38
  def format_duration(sec)
21
39
  return unless sec
22
40
  return "" if sec.is_a?(String) # pg interval support added in Rails 6.1
@@ -5,9 +5,11 @@ module GoodJob
5
5
  module Filterable
6
6
  extend ActiveSupport::Concern
7
7
 
8
- # Caps the largest free-form columns (error, serialized_params->>'arguments')
9
- # fed to to_tsvector() so the combined tsvector stays under PG's ~1 MB limit
10
- # (PG::ProgramLimitExceeded: "string is too long for tsvector").
8
+ # Bounds every document fed to to_tsvector() so the combined tsvector stays
9
+ # under PG's ~1 MB limit (PG::ProgramLimitExceeded: "string is too long for
10
+ # tsvector"). Free-form columns (arguments, error, labels) are truncated, and
11
+ # arguments are excluded from the full serialized_params document since they
12
+ # are covered by their own truncated term.
11
13
  MAX_SEARCH_COLUMN_CHARS = 262_144
12
14
 
13
15
  included do
@@ -74,14 +76,25 @@ module GoodJob
74
76
  (
75
77
  to_tsvector('english', id::text) ||
76
78
  to_tsvector('english', COALESCE(active_job_id::text, '')) ||
77
- to_tsvector('english', serialized_params) ||
79
+ to_tsvector('english', serialized_params - 'arguments') ||
78
80
  to_tsvector('english', COALESCE(LEFT(serialized_params->>'arguments', #{MAX_SEARCH_COLUMN_CHARS}), '')) ||
79
81
  to_tsvector('english', COALESCE(LEFT(error, #{MAX_SEARCH_COLUMN_CHARS}), '')) ||
80
- to_tsvector('english', COALESCE(array_to_string(labels, ' '), ''))
82
+ to_tsvector('english', COALESCE(LEFT(array_to_string(labels, ' '), #{MAX_SEARCH_COLUMN_CHARS}), ''))
81
83
  )
82
84
  SQL
83
85
  to_tsquery_function = database_supports_websearch_to_tsquery? ? 'websearch_to_tsquery' : 'plainto_tsquery'
84
- where("#{tsvector} @@ #{to_tsquery_function}('english', CAST(? AS text))", query)
86
+ # Full-text tokenization does not preserve identifier-like strings (URLs, globs of
87
+ # numbers and letters), so also match the raw text columns as a substring.
88
+ text_match = <<~SQL.squish
89
+ (
90
+ id::text ILIKE :like_pattern OR
91
+ COALESCE(active_job_id::text, '') ILIKE :like_pattern OR
92
+ serialized_params::text ILIKE :like_pattern OR
93
+ COALESCE(error, '') ILIKE :like_pattern OR
94
+ COALESCE(array_to_string(labels, ' '), '') ILIKE :like_pattern
95
+ )
96
+ SQL
97
+ where("#{tsvector} @@ #{to_tsquery_function}('english', CAST(:query AS text)) OR #{text_match}", query: query, like_pattern: "%#{sanitize_sql_like(query)}%")
85
98
  .order(sanitize_sql_for_order([Arel.sql("ts_rank(#{tsvector}, #{to_tsquery_function}('english', CAST(? AS text)))"), query]) => 'DESC')
86
99
  end)
87
100
  end
@@ -110,7 +110,9 @@ module GoodJob # :nodoc:
110
110
  if result.is_a?(String)
111
111
  Fugit.parse(result).within(period).map(&:to_t)
112
112
  else
113
- result
113
+ # A proc that returns a Time is called with the previous run's time, so when cron starts,
114
+ # CronManager#create_task already enqueues every run missed since the last job.
115
+ []
114
116
  end
115
117
  else
116
118
  fugit.within(period).map(&:to_t)
@@ -287,14 +287,20 @@ module GoodJob
287
287
  end
288
288
 
289
289
  def historic_finished_at_index_migrated?
290
- return true if connection.index_name_exists?(table_name, "index_good_jobs_on_queue_name_priority_scheduled_at_unfinished")
290
+ index_exists = connection_pool.with_connection do |connection|
291
+ connection.index_name_exists?(table_name, "index_good_jobs_on_queue_name_priority_scheduled_at_unfinished")
292
+ end
293
+ return true if index_exists
291
294
 
292
295
  migration_pending_warning!
293
296
  false
294
297
  end
295
298
 
296
299
  def lock_type_migrated?
297
- return true if connection.index_name_exists?(table_name, :index_good_jobs_for_candidate_dequeue_unlocked)
300
+ index_exists = connection_pool.with_connection do |connection|
301
+ connection.index_name_exists?(table_name, :index_good_jobs_for_candidate_dequeue_unlocked)
302
+ end
303
+ return true if index_exists
298
304
 
299
305
  migration_pending_warning!
300
306
  false
@@ -957,11 +963,24 @@ module GoodJob
957
963
  job_error = GoodJob::Job::DiscardJobError.new(message)
958
964
 
959
965
  update_record = proc do
960
- update(
961
- finished_at: Time.current,
962
- error: self.class.format_error(job_error),
963
- error_event: :discarded
964
- )
966
+ now = Time.current
967
+ error = self.class.format_error(job_error)
968
+
969
+ transaction do
970
+ update(
971
+ finished_at: now,
972
+ error: error,
973
+ error_event: :discarded
974
+ )
975
+ executions.where(finished_at: nil).find_each do |execution|
976
+ execution.update!(
977
+ finished_at: now,
978
+ error: error,
979
+ error_event: :discarded,
980
+ duration: (now - execution.created_at).seconds
981
+ )
982
+ end
983
+ end
965
984
  end
966
985
 
967
986
  if active_job.respond_to?(:instrument)
@@ -109,7 +109,7 @@
109
109
  </div>
110
110
  </div>
111
111
  <%= tag.div id: dom_id(job, "params"), class: "job-params list-group-item collapse small bg-dark text-light" do %>
112
- <%= tag.pre JSON.pretty_generate(job.display_serialized_params) %>
112
+ <%= tag.pre JSON.pretty_generate(truncate_display_value(job.display_serialized_params)) %>
113
113
  <% end %>
114
114
  <% end %>
115
115
  <% else %>
@@ -56,7 +56,7 @@
56
56
  </div>
57
57
  <div class="col-6 col-lg-1">
58
58
  <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.batch.jobs" %></div>
59
- <%= batch.jobs.size %>
59
+ <%= filter.job_count(batch) %>
60
60
  </div>
61
61
  <div class="col text-end d-flex flex-row justify-content-end">
62
62
  <% if batch.discarded? %>
@@ -70,7 +70,7 @@
70
70
  <% end %>
71
71
  <%= render 'good_job/custom_execution_details', execution: execution, job: @job %>
72
72
  <%= tag.div id: dom_id(execution, "params"), class: "list-group-item collapse small bg-dark text-light" do %>
73
- <%= tag.pre JSON.pretty_generate(execution.display_serialized_params) %>
73
+ <%= tag.pre JSON.pretty_generate(truncate_display_value(execution.display_serialized_params)) %>
74
74
  <% end %>
75
75
  <% end %>
76
76
  </div>
@@ -190,7 +190,7 @@
190
190
  </div>
191
191
  <% end %>
192
192
  <%= tag.div id: dom_id(job, "params"), class: "job-params list-group-item collapse small bg-dark text-light" do %>
193
- <%= tag.pre JSON.pretty_generate(job.display_serialized_params) %>
193
+ <%= tag.pre JSON.pretty_generate(truncate_display_value(job.display_serialized_params)) %>
194
194
  <% end %>
195
195
  <% end %>
196
196
  <% else %>
@@ -78,10 +78,10 @@
78
78
  <% end %>
79
79
  </h5>
80
80
  </div>
81
- <%= tag.pre @job.serialized_params["arguments"].map(&:inspect).join(', '), class: 'text-wrap text-break' %>
81
+ <%= tag.pre @job.serialized_params["arguments"].map { |argument| truncate_display_value(argument).inspect }.join(', '), class: 'text-wrap text-break' %>
82
82
 
83
83
  <%= tag.div id: dom_id(@job, "params"), class: "list-group-item collapse small bg-dark text-light" do %>
84
- <%= tag.pre JSON.pretty_generate(@job.display_serialized_params) %>
84
+ <%= tag.pre JSON.pretty_generate(truncate_display_value(@job.display_serialized_params)) %>
85
85
  <% end %>
86
86
 
87
87
  <%= render 'executions', executions: @job.executions.sort_by(&:number).reverse %>
@@ -1,26 +1,5 @@
1
1
  {
2
2
  "ignored_warnings": [
3
- {
4
- "warning_type": "SQL Injection",
5
- "warning_code": 0,
6
- "fingerprint": "e3cc5489d3e3e81f35347b7530d5743fbac8491ab612ceec0d034e22656269ed",
7
- "check_name": "SQL",
8
- "message": "Possible SQL injection",
9
- "file": "app/models/good_job/job/lockable.rb",
10
- "line": 103,
11
- "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
12
- "code": "unscoped.find_by_sql([\"WITH candidate AS #{(\"MATERIALIZED \" or \"\")}(#{select(:id).lock(\"FOR NO KEY UPDATE SKIP LOCKED\").to_sql})\\nUPDATE #{quoted_table_name}\\nSET locked_by_id = ?,\\n locked_at = ?,\\n lock_type = ?\\nFROM (\\n SELECT id FROM candidate\\n WHERE pg_try_advisory_lock(#{\"('x' || substr(md5(#{_quoted_table_name_string} || '-' || id::text), 1, 16))::bit(64)::bigint\"})\\n) AS locked\\nWHERE #{quoted_table_name}.id = locked.id\\nRETURNING #{quoted_table_name}.*\\n\".squish, locked_by_id, locked_at, lock_types[lock_type.to_s]])",
13
- "render_path": null,
14
- "location": {
15
- "type": "method",
16
- "class": "GoodJob::Job",
17
- "method": "with_hybrid_lock_claim"
18
- },
19
- "user_input": "_quoted_table_name_string",
20
- "confidence": "Weak",
21
- "cwe_id": [89],
22
- "note": "Table name is quoted via quoted_table_name (Rails); advisory lock expression uses _quoted_table_name_string (escaped); candidate SQL is Rails-generated; values are parameterized bindings."
23
- },
24
3
  {
25
4
  "warning_type": "SQL Injection",
26
5
  "warning_code": 0,
@@ -28,18 +7,20 @@
28
7
  "check_name": "SQL",
29
8
  "message": "Possible SQL injection",
30
9
  "file": "app/models/concerns/good_job/advisory_lockable.rb",
31
- "line": 123,
10
+ "line": 254,
32
11
  "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
33
- "code": "joins(\"LEFT JOIN pg_locks ON pg_locks.locktype = 'advisory'\\n AND pg_locks.objsubid = 1\\n AND pg_locks.classid = #{advisory_lock_classid_sql(...)}\\n AND pg_locks.objid = #{advisory_lock_objid_sql(...)}\\n\".squish)",
12
+ "code": "joins(\"LEFT JOIN pg_locks ON pg_locks.locktype = 'advisory'\\n AND pg_locks.objsubid = 1\\n AND pg_locks.classid = #{advisory_lock_classid_sql(\"#{_quoted_table_name_string} || '-' || #{quoted_table_name}.#{adapter_class.quote_column_name(_advisory_lockable_column)}::text\")}\\n AND pg_locks.objid = #{advisory_lock_objid_sql(\"#{_quoted_table_name_string} || '-' || #{quoted_table_name}.#{adapter_class.quote_column_name(_advisory_lockable_column)}::text\")}\\n\".squish)",
34
13
  "render_path": null,
35
14
  "location": {
36
15
  "type": "method",
37
16
  "class": "GoodJob",
38
17
  "method": null
39
18
  },
40
- "user_input": "advisory_lock_classid_sql(...)",
19
+ "user_input": "advisory_lock_classid_sql(\"#{_quoted_table_name_string} || '-' || #{quoted_table_name}.#{adapter_class.quote_column_name(_advisory_lockable_column)}::text\")",
41
20
  "confidence": "Medium",
42
- "cwe_id": [89],
21
+ "cwe_id": [
22
+ 89
23
+ ],
43
24
  "note": "Values are quoted identifiers and an escaped table name string; not user input."
44
25
  },
45
26
  {
@@ -49,30 +30,32 @@
49
30
  "check_name": "SQL",
50
31
  "message": "Possible SQL injection",
51
32
  "file": "app/models/concerns/good_job/advisory_lockable.rb",
52
- "line": 123,
33
+ "line": 254,
53
34
  "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
54
- "code": "joins(\"LEFT JOIN pg_locks ON pg_locks.locktype = 'advisory'\\n AND pg_locks.objsubid = 1\\n AND pg_locks.classid = #{advisory_lock_classid_sql(...)}\\n AND pg_locks.objid = #{advisory_lock_objid_sql(...)}\\n\".squish)",
35
+ "code": "joins(\"LEFT JOIN pg_locks ON pg_locks.locktype = 'advisory'\\n AND pg_locks.objsubid = 1\\n AND pg_locks.classid = #{advisory_lock_classid_sql(\"#{_quoted_table_name_string} || '-' || #{quoted_table_name}.#{adapter_class.quote_column_name(_advisory_lockable_column)}::text\")}\\n AND pg_locks.objid = #{advisory_lock_objid_sql(\"#{_quoted_table_name_string} || '-' || #{quoted_table_name}.#{adapter_class.quote_column_name(_advisory_lockable_column)}::text\")}\\n\".squish)",
55
36
  "render_path": null,
56
37
  "location": {
57
38
  "type": "method",
58
39
  "class": "GoodJob::AdvisoryLockable",
59
40
  "method": null
60
41
  },
61
- "user_input": "advisory_lock_classid_sql(...)",
42
+ "user_input": "advisory_lock_classid_sql(\"#{_quoted_table_name_string} || '-' || #{quoted_table_name}.#{adapter_class.quote_column_name(_advisory_lockable_column)}::text\")",
62
43
  "confidence": "Medium",
63
- "cwe_id": [89],
44
+ "cwe_id": [
45
+ 89
46
+ ],
64
47
  "note": "Values are quoted identifiers and an escaped table name string; not user input."
65
48
  },
66
49
  {
67
50
  "warning_type": "Dangerous Eval",
68
51
  "warning_code": 13,
69
- "fingerprint": "c01ff32ee40cc694540aaa76e3fdab8f330babd9d6dbf2564c32aef1cfd877fa",
52
+ "fingerprint": "54b7ac445eea2aa1735ae97fab1d841e15405a747ba0fdb577cf779286647534",
70
53
  "check_name": "Evaluation",
71
54
  "message": "Dynamic string evaluated as code",
72
55
  "file": "lib/good_job/log_subscriber.rb",
73
- "line": 278,
56
+ "line": 356,
74
57
  "link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/",
75
- "code": "class_eval(\" def #{level}(progname = nil, tags: [], &block) # def info(progname = nil, tags: [], &block)\\n return unless logger # return unless logger\\n #\\n tag_logger(*tags) do # tag_logger(*tags) do\\n logger.#{level}(progname, &block) # logger.info(progname, &block)\\n end # end\\n end #\\n\", \"lib/good_job/log_subscriber.rb\", (278 + 1))",
58
+ "code": "class_eval(\" def #{level}(progname = nil, tags: [], &block) # def info(progname = nil, tags: [], &block)\\n return unless logger # return unless logger\\n #\\n tag_logger(*tags) do # tag_logger(*tags) do\\n logger.#{level}(progname, &block) # logger.info(progname, &block)\\n end # end\\n end #\\n\", \"lib/good_job/log_subscriber.rb\", (356 + 1))",
76
59
  "render_path": null,
77
60
  "location": {
78
61
  "type": "method",
@@ -132,6 +115,29 @@
132
115
  77
133
116
  ],
134
117
  "note": ""
118
+ },
119
+ {
120
+ "warning_type": "SQL Injection",
121
+ "warning_code": 0,
122
+ "fingerprint": "e3cc5489d3e3e81f35347b7530d5743fbac8491ab612ceec0d034e22656269ed",
123
+ "check_name": "SQL",
124
+ "message": "Possible SQL injection",
125
+ "file": "app/models/good_job/job/lockable.rb",
126
+ "line": 102,
127
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
128
+ "code": "unscoped.find_by_sql([\"WITH candidate AS #{(\"MATERIALIZED \" or \"\")}(#{select(:id).lock(\"FOR NO KEY UPDATE SKIP LOCKED\").to_sql})\\nUPDATE #{quoted_table_name}\\nSET locked_by_id = ?,\\n locked_at = ?,\\n lock_type = ?\\nFROM (\\n SELECT id FROM candidate\\n WHERE pg_try_advisory_lock(#{\"('x' || substr(md5(#{_quoted_table_name_string} || '-' || id::text), 1, 16))::bit(64)::bigint\"})\\n) AS locked\\nWHERE #{quoted_table_name}.id = locked.id\\nRETURNING #{quoted_table_name}.*\\n\".squish, locked_by_id, locked_at, lock_types[lock_type.to_s]])",
129
+ "render_path": null,
130
+ "location": {
131
+ "type": "method",
132
+ "class": "GoodJob::Job",
133
+ "method": "with_hybrid_lock_claim"
134
+ },
135
+ "user_input": "_quoted_table_name_string",
136
+ "confidence": "Weak",
137
+ "cwe_id": [
138
+ 89
139
+ ],
140
+ "note": "Table name is quoted via quoted_table_name (Rails); advisory lock expression uses _quoted_table_name_string (escaped); candidate SQL is Rails-generated; values are parameterized bindings."
135
141
  }
136
142
  ],
137
143
  "brakeman_version": "8.0.4"
@@ -80,25 +80,26 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
80
80
  end
81
81
 
82
82
  add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: :index_good_jobs_on_scheduled_at
83
- add_index :good_jobs, [:queue_name, :scheduled_at], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at
84
83
  add_index :good_jobs, [:active_job_id, :created_at], name: :index_good_jobs_on_active_job_id_and_created_at
85
84
  add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished
86
85
  add_index :good_jobs, [:concurrency_key, :created_at], name: :index_good_jobs_on_concurrency_key_and_created_at
87
86
  add_index :good_jobs, [:cron_key, :created_at], where: "(cron_key IS NOT NULL)", name: :index_good_jobs_on_cron_key_and_created_at_cond
88
- add_index :good_jobs, [:cron_key, :cron_at], where: "(cron_key IS NOT NULL)", unique: true, name: :index_good_jobs_on_cron_key_and_cron_at_cond
87
+ add_index :good_jobs, [:cron_key, :cron_at], order: { cron_at: "DESC NULLS LAST" },
88
+ where: "(cron_key IS NOT NULL)", unique: true, name: :index_good_jobs_on_cron_key_and_cron_at_cond
89
89
  add_index :good_jobs, [:finished_at], where: "finished_at IS NOT NULL", name: :index_good_jobs_jobs_on_finished_at_only
90
- add_index :good_jobs, [:priority, :created_at], order: { priority: "DESC NULLS LAST", created_at: :asc },
91
- where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished
92
90
  add_index :good_jobs, [:priority, :created_at], order: { priority: "ASC NULLS LAST", created_at: :asc },
93
91
  where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup
94
92
  add_index :good_jobs, [:batch_id], where: "batch_id IS NOT NULL"
95
93
  add_index :good_jobs, [:batch_callback_id], where: "batch_callback_id IS NOT NULL"
94
+ add_index :good_jobs, :batch_id,
95
+ where: "batch_id IS NOT NULL AND finished_at IS NULL", name: :index_good_jobs_on_batch_id_unfinished
96
+ add_index :good_jobs, :batch_callback_id,
97
+ where: "batch_callback_id IS NOT NULL AND finished_at IS NULL", name: :index_good_jobs_on_batch_callback_id_unfinished
96
98
  add_index :good_jobs, :job_class, name: :index_good_jobs_on_job_class
97
99
  add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", name: :index_good_jobs_on_labels
98
100
 
99
101
  add_index :good_job_executions, [:active_job_id, :created_at], name: :index_good_job_executions_on_active_job_id_and_created_at
100
- add_index :good_jobs, [:priority, :scheduled_at], order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
101
- where: "finished_at IS NULL AND locked_by_id IS NULL", name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked
102
+ add_index :good_job_executions, :scheduled_at, name: :index_good_job_executions_on_scheduled_at
102
103
  add_index :good_jobs, [:priority, :scheduled_at, :id],
103
104
  where: "finished_at IS NULL", name: "index_good_jobs_on_priority_scheduled_at_unfinished"
104
105
  add_index :good_jobs, [:queue_name, :scheduled_at, :id],
@@ -107,11 +108,24 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
107
108
  add_index :good_jobs, :locked_by_id,
108
109
  where: "locked_by_id IS NOT NULL", name: "index_good_jobs_on_locked_by_id"
109
110
  add_index :good_job_executions, [:process_id, :created_at], name: :index_good_job_executions_on_process_id_and_created_at
111
+ add_index :good_job_batches, :finished_at,
112
+ where: "finished_at IS NOT NULL", name: :index_good_job_batches_on_finished_at
113
+ add_index :good_job_batches, [:created_at, :id],
114
+ order: { created_at: :desc, id: :desc }, name: :index_good_job_batches_on_created_at_and_id
110
115
  add_index :good_jobs, [:priority, :scheduled_at, :id],
111
116
  name: :index_good_jobs_for_candidate_dequeue_unlocked,
112
117
  order: { priority: "ASC NULLS LAST", scheduled_at: :asc, id: :asc },
113
118
  where: "finished_at IS NULL AND locked_by_id IS NULL"
114
119
 
120
+ add_index :good_jobs, [:queue_name, :priority, :created_at],
121
+ name: :index_good_jobs_dequeue_by_queue,
122
+ order: { priority: "ASC NULLS LAST", created_at: :asc },
123
+ where: "finished_at IS NULL AND locked_by_id IS NULL"
124
+ add_index :good_jobs, [:queue_name, :priority, :scheduled_at],
125
+ name: :index_good_jobs_dequeue_by_queue_scheduled_at,
126
+ order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
127
+ where: "finished_at IS NULL AND locked_by_id IS NULL"
128
+
115
129
  add_index :good_jobs, :queue_name, name: :index_good_jobs_on_queue_name
116
130
  add_index :good_jobs, :created_at, name: :index_good_jobs_on_created_at
117
131
  add_index :good_jobs, :finished_at,
@@ -79,23 +79,24 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
79
79
  end
80
80
 
81
81
  add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: :index_good_jobs_on_scheduled_at
82
- add_index :good_jobs, [:queue_name, :scheduled_at], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at
83
82
  add_index :good_jobs, [:active_job_id, :created_at], name: :index_good_jobs_on_active_job_id_and_created_at
84
83
  add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished
85
84
  add_index :good_jobs, [:cron_key, :created_at], where: "(cron_key IS NOT NULL)", name: :index_good_jobs_on_cron_key_and_created_at_cond
86
- add_index :good_jobs, [:cron_key, :cron_at], where: "(cron_key IS NOT NULL)", unique: true, name: :index_good_jobs_on_cron_key_and_cron_at_cond
85
+ add_index :good_jobs, [:cron_key, :cron_at], order: { cron_at: "DESC NULLS LAST" },
86
+ where: "(cron_key IS NOT NULL)", unique: true, name: :index_good_jobs_on_cron_key_and_cron_at_cond
87
87
  add_index :good_jobs, [:finished_at], where: "retried_good_job_id IS NULL AND finished_at IS NOT NULL", name: :index_good_jobs_jobs_on_finished_at
88
- add_index :good_jobs, [:priority, :created_at], order: { priority: "DESC NULLS LAST", created_at: :asc },
89
- where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished
90
88
  add_index :good_jobs, [:priority, :created_at], order: { priority: "ASC NULLS LAST", created_at: :asc },
91
89
  where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup
92
90
  add_index :good_jobs, [:batch_id], where: "batch_id IS NOT NULL"
93
91
  add_index :good_jobs, [:batch_callback_id], where: "batch_callback_id IS NOT NULL"
92
+ add_index :good_jobs, :batch_id,
93
+ where: "batch_id IS NOT NULL AND finished_at IS NULL", name: :index_good_jobs_on_batch_id_unfinished
94
+ add_index :good_jobs, :batch_callback_id,
95
+ where: "batch_callback_id IS NOT NULL AND finished_at IS NULL", name: :index_good_jobs_on_batch_callback_id_unfinished
94
96
  add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", name: :index_good_jobs_on_labels
95
97
 
96
98
  add_index :good_job_executions, [:active_job_id, :created_at], name: :index_good_job_executions_on_active_job_id_and_created_at
97
- add_index :good_jobs, [:priority, :scheduled_at], order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
98
- where: "finished_at IS NULL AND locked_by_id IS NULL", name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked
99
+ add_index :good_job_executions, :scheduled_at, name: :index_good_job_executions_on_scheduled_at
99
100
  add_index :good_jobs, :locked_by_id,
100
101
  where: "locked_by_id IS NOT NULL", name: "index_good_jobs_on_locked_by_id"
101
102
  add_index :good_jobs, [:priority, :scheduled_at, :id],
@@ -103,5 +104,9 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
103
104
  order: { priority: "ASC NULLS LAST", scheduled_at: :asc, id: :asc },
104
105
  where: "finished_at IS NULL AND locked_by_id IS NULL"
105
106
  add_index :good_job_executions, [:process_id, :created_at], name: :index_good_job_executions_on_process_id_and_created_at
107
+ add_index :good_job_batches, :finished_at,
108
+ where: "finished_at IS NOT NULL", name: :index_good_job_batches_on_finished_at
109
+ add_index :good_job_batches, [:created_at, :id],
110
+ order: { created_at: :desc, id: :desc }, name: :index_good_job_batches_on_created_at_and_id
106
111
  end
107
112
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddIndexGoodJobsDequeueByQueue < ActiveRecord::Migration<%= migration_version %>
4
+ disable_ddl_transaction!
5
+
6
+ def change
7
+ add_index :good_jobs, [:queue_name, :priority, :created_at],
8
+ name: :index_good_jobs_dequeue_by_queue,
9
+ order: { priority: "ASC NULLS LAST", created_at: :asc },
10
+ where: "finished_at IS NULL AND locked_by_id IS NULL",
11
+ algorithm: :concurrently,
12
+ if_not_exists: true
13
+
14
+ add_index :good_jobs, [:queue_name, :priority, :scheduled_at],
15
+ name: :index_good_jobs_dequeue_by_queue_scheduled_at,
16
+ order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
17
+ where: "finished_at IS NULL AND locked_by_id IS NULL",
18
+ algorithm: :concurrently,
19
+ if_not_exists: true
20
+ end
21
+ end