good_job 3.27.4 → 3.28.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa73a0d9ffc1feb2548c525af767537d79a8b6fdf4e03092c4963d91041eb73a
4
- data.tar.gz: 41365ce2085788ff587d85a96d6bf1568fb5861ea025e98c87990ae65485ffc8
3
+ metadata.gz: b22f482dcf118c82d0b5889c6c37fd72bb65ff9a010f4fcc486bc2f0c4fc0bbe
4
+ data.tar.gz: 83b1d597eee0f0c9c37b4afdf82f96e2164bef2d3b27181c10678327803f718e
5
5
  SHA512:
6
- metadata.gz: a300b98ed92353715dff14d18940f0e9fa2896e1cda56428c026e62b162e3f7a2f5a07eba2a9501af2d0c1f4fa51f3c225a4ecc103f73e8eac862ec343902e6e
7
- data.tar.gz: 25241155b1d5e375a903cbeec98e88ecaaca2fc46b1134c92ed400fbf2b024a637744db072fe42fb9af486e7ce43cec5262c6c31adceb2f948af3aacb7cc9584
6
+ metadata.gz: b0b84290323a36b818cf92763e9df745737099f3990c934914e8ae700d6cb3a2ceb237764a8ff24fe923d5a8af0d18394e14a6aec516085a2bf057579341747e
7
+ data.tar.gz: 030e271e5218499dbbce39e21803a5da6ede65422addb33ba5a0024582ac0996418471664061ce54700b1e9a5819eeeac517c479bb79d329577985e148752811
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [v3.28.0](https://github.com/bensheldon/good_job/tree/v3.28.0) (2024-04-19)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.27.4...v3.28.0)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - Store and optionally display the full execution backtrace [\#1328](https://github.com/bensheldon/good_job/pull/1328) ([Earlopain](https://github.com/Earlopain))
10
+ - Store error backtraces on discrete executions [\#1325](https://github.com/bensheldon/good_job/pull/1325) ([Intrepidd](https://github.com/Intrepidd))
11
+
12
+ **Fixed bugs:**
13
+
14
+ - add missing dropdown-item class [\#1327](https://github.com/bensheldon/good_job/pull/1327) ([patriciomacadden](https://github.com/patriciomacadden))
15
+
16
+ **Closed issues:**
17
+
18
+ - Proposal: Migrating Documentation to a Separate Website [\#1324](https://github.com/bensheldon/good_job/issues/1324)
19
+ - Potential documentation error in GoodJob::ActiveJobExtensions::NotifyOptions [\#1321](https://github.com/bensheldon/good_job/issues/1321)
20
+ - ActiveSupport::CurrentAttributes reset after `perform_later` [\#1320](https://github.com/bensheldon/good_job/issues/1320)
21
+ - Storing backtrace in database? [\#1162](https://github.com/bensheldon/good_job/issues/1162)
22
+ - Potential locking race condition when using cron scheduler across multiple processes [\#731](https://github.com/bensheldon/good_job/issues/731)
23
+
24
+ **Merged pull requests:**
25
+
26
+ - docs: corrected a typo regarding the use of GoodJob::ActiveJobExtensions::NotifyOptions [\#1322](https://github.com/bensheldon/good_job/pull/1322) ([pgvsalamander](https://github.com/pgvsalamander))
27
+ - Add "best practices" section to Readme [\#1318](https://github.com/bensheldon/good_job/pull/1318) ([bensheldon](https://github.com/bensheldon))
28
+ - Change ApplicationRecord to ApplicationJob for label documentation. [\#1317](https://github.com/bensheldon/good_job/pull/1317) ([frans-k](https://github.com/frans-k))
29
+ - Run test matrix against Ruby 3.3; remove pry [\#1315](https://github.com/bensheldon/good_job/pull/1315) ([bensheldon](https://github.com/bensheldon))
30
+ - Add `Rails.application.load_server` to Demo `config.ru`; quiet puma web-concurrency warnings [\#1314](https://github.com/bensheldon/good_job/pull/1314) ([bensheldon](https://github.com/bensheldon))
31
+ - Fix test leakage of configuration double [\#1312](https://github.com/bensheldon/good_job/pull/1312) ([bensheldon](https://github.com/bensheldon))
32
+ - Rewrite queries to all use bind parameters and prepare: true [\#1308](https://github.com/bensheldon/good_job/pull/1308) ([bensheldon](https://github.com/bensheldon))
33
+
3
34
  ## [v3.27.4](https://github.com/bensheldon/good_job/tree/v3.27.4) (2024-04-04)
4
35
 
5
36
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.27.3...v3.27.4)
data/README.md CHANGED
@@ -72,6 +72,11 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
72
72
  - [Write tests](#write-tests)
73
73
  - [PgBouncer compatibility](#pgbouncer-compatibility)
74
74
  - [CLI HTTP health check probes](#cli-http-health-check-probes)
75
+ - [Doing your best job with GoodJob](#doing-your-best-job-with-goodjob)
76
+ - [Sizing jobs: mice and elephants](#sizing-jobs-mice-and-elephants)
77
+ - [Isolating by total latency](#isolating-by-total-latency)
78
+ - [Configuring your queues](#configuring-your-queues)
79
+ - [Additional observations](#additional-observations)
75
80
  - [Contribute](#contribute)
76
81
  - [Gem development](#gem-development)
77
82
  - [Development setup](#development-setup)
@@ -500,12 +505,12 @@ Higher priority numbers run first in all versions of GoodJob v3.x and below. Goo
500
505
  Labels are the recommended way to add context or metadata to specific jobs. For example, all jobs that have a dependency on an email service could be labeled `email`. Using labels requires adding the Active Job extension `GoodJob::ActiveJobExtensions::Labels` to your job class.
501
506
 
502
507
  ```ruby
503
- class ApplicationRecord < ActiveJob::Base
508
+ class ApplicationJob < ActiveJob::Base
504
509
  include GoodJob::ActiveJobExtensions::Labels
505
510
  end
506
511
 
507
512
  # Add a default label to every job within the class
508
- class WelcomeJob < ApplicationRecord
513
+ class WelcomeJob < ApplicationJob
509
514
  self.good_job_labels = ["email"]
510
515
 
511
516
  def perform
@@ -1527,6 +1532,90 @@ gem 'webrick'
1527
1532
 
1528
1533
  If WEBrick is configured to be used, but the dependency is not found, GoodJob will log a warning and fallback to the default probe server.
1529
1534
 
1535
+ ## Doing your best job with GoodJob
1536
+
1537
+ _This section explains how to use GoodJob the most efficiently and performantly, according to its maintainers. GoodJob is very flexible and you don’t necessarily have to use it this way, but the concepts explained here are part of GoodJob’s design intent._
1538
+
1539
+ Background jobs are hard. There are two extremes:
1540
+
1541
+ - **Throw resources (compute, servers, money) at it** by creating dedicated processes (or servers) for each type of job or queue and scaling them independently to achieve the lowest latency and highest throughput.
1542
+ - **Do the best you can in a small budget** by creating dedicated _thread pools_ within a process for each type of job or queue to produce quality-of-service and compromise maximum latency (or tail latency) because of shared resources and thread contention. You can even run them in the web process if you’re really cheap.
1543
+
1544
+ This section will largely focused on optimizing within the latter small-budget scenario, but the concepts and explanation should help you optimize the big-budget scenario too.
1545
+
1546
+ Let’s start with anti-patterns, and then the rest of this section will explain an alternative:
1547
+
1548
+ - **Don’t use functional names for your queues** like `mailers` or `sms` or `turbo` or `batch`. Instead name them after the total latency target (the total duration within queue and executing till finish) you expect for that job e.g.`latency_30s` or `latency_5m` or `literally_whenever`.
1549
+ - **Priority can’t fix a lack of capacity.** Priority rules (i.e. weighing or ordering which jobs or queues execute first) only works when there is capacity available to execute that _next_ job. When all capacity is in-use, priority cannot preempt a job that is already executing ("head-of-line blocking").
1550
+
1551
+ The following will explain methods to create homogenous workloads (based on latency) and increase execution capacity when queuing latency causes the jobs to exceed their total latency target.
1552
+
1553
+ ### Sizing jobs: mice and elephants
1554
+
1555
+ Queuing theory will refer to fast/small/low-latency tasks as **Mice** (e.g. a password reset email, an MFA token via SMS) and slow/big/high-latency tasks as **Elephants** (e.g. sending an email newsletter to 10k recipients, a batched update that touches every record in the database).
1556
+
1557
+ Explicitly group your jobs by their latency: how quickly you expect them to finish to achieve your expected quality of service. This should be their **total latency** (or duration) which is the sum of: **queuing latency** which is how long the job waits in queue until execution capacity becomes available (which ideally should be zero, because you have idle capacity and can start executing a job immediately as soon as it is enqueued or upon its scheduled time) and **execution latency** which is how long the job’s execution takes (e.g. the email being sent). Example: I expect this Password Reset Email Job to have a total latency of 30 seconds or less.
1558
+
1559
+ In a working application, you likely will have more gradations than just small and big or slow and fast (analogously: badgers, wildebeests; maybe even tardigrades or blue whales for tiny and huge, respectively), but there will regardless be a relatively small and countable number of discrete latency buckets to organize your jobs into.
1560
+
1561
+ ### Isolating by total latency
1562
+
1563
+ The most efficient workloads are homogenous (similar) workloads. If you know every job to be executed will take about the same amount of time, you can estimate the maximum delay for a new job at the back of the queue and have that drive decisions about capacity. Alternatively, if those jobs are heterogenous (mixed) it’s possible that a very slow/long-duration job could hold everything back for much longer than anticipated and it’s sorta random. That’s bad!
1564
+
1565
+ A fun visual image here for a single-file queue is a doorway: If you only have 1 doorway, it must be big enough to fit an elephant. But if an elephant is going through the door (and it will go through slowly!) no mice can fit through the door until the elephant is fully clear. Your mice will be delayed!
1566
+
1567
+ Priority will not help when an elephant is in the doorway. Yes, you could say mice have a higher priority than elephants and always allow any mouse to go _before_ any elephant in queue will start. But once an elephant *has started* going through the door, any subsequent mouse who arrives must wait for the elephant to egress regardless of their priority. In Active Job and Ruby, it’s really hard to stop or cancel or preempt a running job (unless you’ve already architected that into your jobs, like with the [`job-iteration`](https://github.com/Shopify/job-iteration) library)
1568
+
1569
+ The best solution is to have a 2nd door, but only sized for mice, so an elephant can’t ever block it. With a mouse-sized doorway _and_ an elephant-sized doorway, mice can still go through the big elephant door when an elephant isn’t using it. Each door has a _maximum_ size (or “latency”) we want it to accept, and smaller is ok, just not larger.
1570
+
1571
+ ### Configuring your queues
1572
+
1573
+ If we wanted to capture the previous 2-door scenario in GoodJob, we’d configure the queues like this;
1574
+
1575
+ ```ruby
1576
+ config.good_job.queues = "mice:1; elephant,mice:1"
1577
+ ```
1578
+
1579
+ This configuration creates two isolated thread pools (separated by a semicolon) each with 1 thread each (the number after the colon). The 2nd thread pool recognizes that both elephants and mice can use that isolated thread pool; if there is an influx of mice, it's possible to use the elephant’s thread pool if an elephant isn't already in progress.
1580
+
1581
+ So what if we add an intermediately-sized `badgers` ? In that case, we can make 3 distinct queues:
1582
+
1583
+ ```ruby
1584
+ config.good_job.queues = "mice:1; badgers,mice:1; elephants,badgers,mice:1"
1585
+ ```
1586
+
1587
+ In this case, we make a mouse sized queue, a badger sized queue, and an elephant sized queue. We can simplify this even further:
1588
+
1589
+ ```ruby
1590
+ config.good_job.queues = "mice:1; badgers,mice:1; *:1"
1591
+ ```
1592
+
1593
+ Using the wildcard `*` for any queue also helps ensure that if a job is enqueued to a newly declared queue (maybe via a dependency or just inadvertently) it will still get executed until you notice and decide on its appropriate latency target.
1594
+
1595
+ In these examples, the order doesn’t matter; it just is maybe more readable to go from the lowest-latency to largest-latency pool (the semicolon groups), and then within a pool to list the largest allowable latency first (the commas). Nothing here is about “job priority” or “queue priority”, this is wholly about grouping.
1596
+
1597
+ In your application, not the zoo, you’ll want to enqueue your `PaswordResetJob` on the `mice` queue, your `CreateComplicatedObjectJob` on the `badger` queue, and your `AuditEveryAccountEverJob` on the `elephant` queue. But you want to name your queues by latency, so that ends up being:
1598
+
1599
+ ```ruby
1600
+ config.good_job.queues = "latency_30s:1; latency_2m,latency_30s:1; *:1"
1601
+ ```
1602
+
1603
+ And you likely want to have more than one thread (though more than 3-5 threads per process will cause thread contention and slow everything down a bit):
1604
+
1605
+ ```ruby
1606
+ config.good_job.queues = "latency_30s:2; latency_2m,latency_30s:2; *:2"
1607
+ ```
1608
+
1609
+ ### Additional observations
1610
+
1611
+ - Unlike GoodJob, other Active Job backends may treat a "queue" and an "isolated execution pool" as one and the same. GoodJob allows composing multiple Active Job queues into the same pool for flexibility and to make it easier to migrate from functionally-named queues to latency-based ones.
1612
+ - You don't *have* to name your queues explicitly like `latency_30s` but it makes it easier to identify outliers and communicate your operational targets. Many people push back on this; that's ok. An option to capture functional details is to use GoodJob's Labels feature instead of encoding them in the queue name.
1613
+ - The downside of organizing your jobs like this is that you may have jobs with the same latency target but wildly different operational parameters, like being coupled to another system that has limited throughput or questionable reliability. GoodJob offers Concurrency and Throttling Controls, but isolation is always the most performant and reliable option, though it requires dedicated resources and costs more.
1614
+ - Observe, monitor, and adapt your job queues over time. You likely have incomplete information about the execution latency of your jobs inclusive of all dependencies across all scenarios. You should expect to adjust your queues and grouping over time as you observe their behavior.
1615
+ - If you find you have unreliable external dependencies that introduce latency, you may also want to further isolate your jobs based on those dependencies, for example, isolating `latency_10s_email_service` to its own execution pool.
1616
+ - Scale on queue latency. Per the previous point in which you do not have complete control over execution latency, you do have control over the queue latency. If queue latency is causing your jobs to miss their total latency target, you must add more capacity (e.g. processes or servers.
1617
+ - This is all largely about latency-based queue design. It’s possible to go further and organize by latency _and_ parallelism. For that I recommend Nate Berkopec’s [*Complete Guide to Rails Performance*](https://www.railsspeed.com/) which covers things like Amdahl’s Law.
1618
+
1530
1619
  ## Contribute
1531
1620
 
1532
1621
  <!-- Please keep this section in sync with CONTRIBUTING.md -->
@@ -42,3 +42,7 @@
42
42
  .min-w-auto {
43
43
  min-width: auto;
44
44
  }
45
+
46
+ .w-fit-content {
47
+ width: fit-content
48
+ }
@@ -46,14 +46,19 @@ module GoodJob
46
46
  cte_query = cte_query.limit(select_limit) if select_limit
47
47
  cte_type = supports_cte_materialization_specifiers? ? :MATERIALIZED : :""
48
48
  composed_cte = Arel::Nodes::As.new(cte_table, Arel::Nodes::UnaryOperation.new(cte_type, cte_query.arel))
49
+
50
+ lock_condition = "#{function}(('x' || substr(md5(#{connection.quote(table_name)} || '-' || #{connection.quote_table_name(cte_table.name)}.#{connection.quote_column_name(column)}::text), 1, 16))::bit(64)::bigint)"
49
51
  query = cte_table.project(cte_table[:id])
50
52
  .with(composed_cte)
51
- .where(Arel.sql("#{function}(('x' || substr(md5(#{connection.quote(table_name)} || '-' || #{connection.quote_table_name(cte_table.name)}.#{connection.quote_column_name(column)}::text), 1, 16))::bit(64)::bigint)"))
53
+ .where(defined?(Arel::Nodes::BoundSqlLiteral) ? Arel::Nodes::BoundSqlLiteral.new(lock_condition, [], {}) : Arel::Nodes::SqlLiteral.new(lock_condition))
52
54
 
53
55
  limit = original_query.arel.ast.limit
54
56
  query.limit = limit.value if limit.present?
55
57
 
56
- unscoped.where(arel_table[primary_key].in(query)).merge(original_query.only(:order))
58
+ # Arel.sql and the IN clause prevent this from being preparable
59
+ # That's why this is manually composed of BoundSqlLiteral's and an InfixOperation
60
+ # to sidestep anywhere in Arel where the `collector.preparable = false` is set
61
+ unscoped.where(Arel::Nodes::InfixOperation.new("IN", arel_table[primary_key], query)).merge(original_query.only(:order))
57
62
  end)
58
63
 
59
64
  # Joins the current query with Postgres's +pg_locks+ table (it provides
@@ -17,9 +17,9 @@ module GoodJob
17
17
  scope :display_all, (lambda do |after_scheduled_at: nil, after_id: nil|
18
18
  query = order(Arel.sql('COALESCE(scheduled_at, created_at) DESC, id DESC'))
19
19
  if after_scheduled_at.present? && after_id.present?
20
- query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at), id) < (:after_scheduled_at, :after_id)'), after_scheduled_at: after_scheduled_at, after_id: after_id)
20
+ query = query.where Arel::Nodes::Grouping.new([coalesce_scheduled_at_created_at, arel_table["id"]]).lt(Arel::Nodes::Grouping.new([bind_value('coalesce', after_scheduled_at, ActiveRecord::Type::DateTime), bind_value('id', after_id, ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Uuid)]))
21
21
  elsif after_scheduled_at.present?
22
- query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at)) < (:after_scheduled_at)'), after_scheduled_at: after_scheduled_at)
22
+ query = query.where coalesce_scheduled_at_created_at.lt(bind_value('coalesce', after_scheduled_at, ActiveRecord::Type::DateTime))
23
23
  end
24
24
  query
25
25
  end)
@@ -34,6 +34,7 @@ module GoodJob
34
34
  query = query.to_s.strip
35
35
  next if query.blank?
36
36
 
37
+ # TODO: turn this into proper bind parameters in Arel
37
38
  tsvector = "(to_tsvector('english', id::text) || to_tsvector('english', COALESCE(active_job_id::text, '')) || to_tsvector('english', serialized_params) || to_tsvector('english', COALESCE(error, ''))#{" || to_tsvector('english', COALESCE(array_to_string(labels, ' '), ''))" if labels_migrated?})"
38
39
  to_tsquery_function = database_supports_websearch_to_tsquery? ? 'websearch_to_tsquery' : 'plainto_tsquery'
39
40
  where("#{tsvector} @@ #{to_tsquery_function}(?)", query)
@@ -40,6 +40,10 @@ module GoodJob
40
40
  end
41
41
  end
42
42
 
43
+ def self.bind_value(name, value, type_class)
44
+ Arel::Nodes::BindParam.new(ActiveRecord::Relation::QueryAttribute.new(name, value, type_class.new))
45
+ end
46
+
43
47
  ActiveSupport.run_load_hooks(:good_job_base_record, self)
44
48
  end
45
49
  end
@@ -18,6 +18,8 @@ module GoodJob
18
18
  scope :not_discarded, -> { where(discarded_at: nil) }
19
19
  scope :succeeded, -> { finished.not_discarded }
20
20
 
21
+ scope :finished_before, ->(timestamp) { where(arel_table['finished_at'].lteq(bind_value('finished_at', timestamp, ActiveRecord::Type::DateTime))) }
22
+
21
23
  alias_attribute :enqueued?, :enqueued_at
22
24
  alias_attribute :discarded?, :discarded_at
23
25
  alias_attribute :finished?, :finished_at
@@ -25,9 +27,13 @@ module GoodJob
25
27
  scope :display_all, (lambda do |after_created_at: nil, after_id: nil|
26
28
  query = order(created_at: :desc, id: :desc)
27
29
  if after_created_at.present? && after_id.present?
28
- query = query.where(Arel.sql('(created_at, id) < (:after_created_at, :after_id)'), after_created_at: after_created_at, after_id: after_id)
30
+ query = if Gem::Version.new(Rails.version) < Gem::Version.new('7.0.0.a') || Concurrent.on_jruby?
31
+ query.where(Arel.sql('(created_at, id) < (:after_created_at, :after_id)'), after_created_at: after_created_at, after_id: after_id)
32
+ else
33
+ query.where Arel::Nodes::Grouping.new([arel_table["created_at"], arel_table["id"]]).lt(Arel::Nodes::Grouping.new([bind_value('created_at', after_created_at, ActiveRecord::Type::DateTime), bind_value('id', after_id, ActiveRecord::Type::String)]))
34
+ end
29
35
  elsif after_created_at.present?
30
- query = query.where(Arel.sql('(after_created_at) < (:after_created_at)'), after_created_at: after_created_at)
36
+ query = query.where arel_table["created_at"].lt(bind_value('created_at', after_created_at, ActiveRecord::Type::DateTime))
31
37
  end
32
38
  query
33
39
  end)
@@ -21,6 +21,13 @@ module GoodJob # :nodoc:
21
21
  false
22
22
  end
23
23
 
24
+ def self.backtrace_migrated?
25
+ return true if columns_hash["error_backtrace"].present?
26
+
27
+ migration_pending_warning!
28
+ false
29
+ end
30
+
24
31
  def number
25
32
  serialized_params.fetch('executions', 0) + 1
26
33
  end
@@ -58,5 +65,9 @@ module GoodJob # :nodoc:
58
65
  _good_job_execution: attributes.except('serialized_params'),
59
66
  })
60
67
  end
68
+
69
+ def filtered_error_backtrace
70
+ Rails.backtrace_cleaner.clean(error_backtrace || [])
71
+ end
61
72
  end
62
73
  end
@@ -95,7 +95,7 @@ module GoodJob
95
95
  # @!method only_scheduled
96
96
  # @!scope class
97
97
  # @return [ActiveRecord::Relation]
98
- scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(Arel::Nodes::BindParam.new(ActiveModel::Attribute.with_cast_value("scheduled_at", Time.current, ActiveModel::Type::DateTime.new)))).or(where(scheduled_at: nil)) }
98
+ scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(bind_value('scheduled_at', DateTime.current, ActiveRecord::Type::DateTime))).or(where(scheduled_at: nil)) }
99
99
 
100
100
  # Order executions by priority (highest priority first).
101
101
  # @!method priority_ordered
@@ -161,7 +161,7 @@ module GoodJob
161
161
  # @param timestamp (Float)
162
162
  # Get jobs that finished before this time (in epoch time).
163
163
  # @return [ActiveRecord::Relation]
164
- scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(timestamp)) : where.not(finished_at: nil) }
164
+ scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(bind_value('finished_at', timestamp, ActiveRecord::Type::DateTime))) : where.not(finished_at: nil) }
165
165
 
166
166
  # Get Jobs that started but not finished yet.
167
167
  # @!method running
@@ -290,11 +290,12 @@ module GoodJob
290
290
  query = advisory_unlocked.unfinished.schedule_ordered
291
291
 
292
292
  after ||= Time.current
293
- after_query = query.where('scheduled_at > ?', after).or query.where(scheduled_at: nil).where('created_at > ?', after)
293
+ after_bind = bind_value('scheduled_at', after, ActiveRecord::Type::DateTime)
294
+ after_query = query.where(arel_table['scheduled_at'].gt(after_bind)).or query.where(scheduled_at: nil).where(arel_table['created_at'].gt(after_bind))
294
295
  after_at = after_query.limit(limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
295
296
 
296
297
  if now_limit&.positive?
297
- now_query = query.where('scheduled_at < ?', Time.current).or query.where(scheduled_at: nil)
298
+ now_query = query.where(arel_table['scheduled_at'].lt(bind_value('scheduled_at', Time.current, ActiveRecord::Type::DateTime))).or query.where(scheduled_at: nil)
298
299
  now_at = now_query.limit(now_limit).pluck(:scheduled_at, :created_at).map { |timestamps| timestamps.compact.first }
299
300
  end
300
301
 
@@ -460,6 +461,7 @@ module GoodJob
460
461
  if discrete_execution
461
462
  discrete_execution.error = error_string
462
463
  discrete_execution.error_event = result.error_event
464
+ discrete_execution.error_backtrace = job_error.backtrace if discrete_execution.class.backtrace_migrated?
463
465
  end
464
466
  else
465
467
  job_attributes[:error] = nil
@@ -44,14 +44,14 @@ module GoodJob
44
44
  # @!scope class
45
45
  # @param timestamp (DateTime, Time)
46
46
  # @return [ActiveRecord::Relation]
47
- scope :finished_before, ->(timestamp) { where(arel_table['finished_at'].lteq(timestamp)) }
47
+ scope :finished_before, ->(timestamp) { where(arel_table['finished_at'].lteq(bind_value('finished_at', timestamp, ActiveRecord::Type::DateTime))) }
48
48
 
49
49
  # First execution will run in the future
50
- scope :scheduled, -> { where(finished_at: nil).where(coalesce_scheduled_at_created_at.gt(DateTime.current)).where(params_execution_count.lt(2)) }
50
+ scope :scheduled, -> { where(finished_at: nil).where(coalesce_scheduled_at_created_at.gt(bind_value('coalesce', Time.current, ActiveRecord::Type::DateTime))).where(params_execution_count.lt(2)) }
51
51
  # Execution errored, will run in the future
52
- scope :retried, -> { where(finished_at: nil).where(coalesce_scheduled_at_created_at.gt(DateTime.current)).where(params_execution_count.gt(1)) }
52
+ scope :retried, -> { where(finished_at: nil).where(coalesce_scheduled_at_created_at.gt(bind_value('coalesce', Time.current, ActiveRecord::Type::DateTime))).where(params_execution_count.gt(1)) }
53
53
  # Immediate/Scheduled time to run has passed, waiting for an available thread run
54
- scope :queued, -> { where(finished_at: nil).where(coalesce_scheduled_at_created_at.lteq(DateTime.current)).joins_advisory_locks.where(pg_locks: { locktype: nil }) }
54
+ scope :queued, -> { where(finished_at: nil).where(coalesce_scheduled_at_created_at.lteq(bind_value('coalesce', Time.current, ActiveRecord::Type::DateTime))).joins_advisory_locks.where(pg_locks: { locktype: nil }) }
55
55
  # Advisory locked and executing
56
56
  scope :running, -> { where(finished_at: nil).joins_advisory_locks.where.not(pg_locks: { locktype: nil }) }
57
57
  # Finished executing (succeeded or discarded)
@@ -35,9 +35,35 @@
35
35
  </div>
36
36
  <% if execution.error %>
37
37
  <div class="mt-3 small">
38
- <strong class="small"><%=t "good_job.shared.error" %>:</strong>
38
+ <strong class="small text-danger"><%=t "good_job.shared.error" %>:</strong>
39
39
  <code class="text-wrap text-break m-0 text-secondary-emphasis"><%= execution.error %></code>
40
40
  </div>
41
+ <% if GoodJob::DiscreteExecution.backtrace_migrated? && execution.error_backtrace&.any? %>
42
+ <%= tag.ul class: "nav nav-tabs small w-fit-content", id: dom_id(execution, :tab), role:"tablist" do %>
43
+ <li class="nav-item" role="presentation">
44
+ <%= tag.button t(".application_trace"), class: "nav-link active p-1", id: dom_id(execution, :application), data: { bs_toggle: "tab", bs_target: dom_id(execution, :"#application_pane") }, type: "button", role: "tab", aria: { controls: dom_id(execution, :application_pane), selected: true } %>
45
+ </li>
46
+ <li class="nav-item" role="presentation">
47
+ <%= tag.button t(".full_trace"), class: "nav-link p-1", id: dom_id(execution, :full), data: { bs_toggle: "tab", bs_target: dom_id(execution, :"#full_pane") }, type: "button", role: "tab", aria: { controls: dom_id(execution, :full_pane), selected: false } %>
48
+ </li>
49
+ <% end %>
50
+ <%= tag.div class: "tab-content", id: "#{dom_id(execution, :tab)}Content" do %>
51
+ <%= tag.div class: "tab-pane fade show active", id: dom_id(execution, :application_pane), role: "tabpane", aria: { labelledby: dom_id(execution, :application) }, tabindex: 0 do %>
52
+ <div class="small">
53
+ <code class="text-wrap text-break m-0 text-secondary-emphasis">
54
+ <%= safe_join(execution.filtered_error_backtrace, tag.br) %>
55
+ </code>
56
+ </div>
57
+ <% end %>
58
+ <%= tag.div class: "tab-pane fade", id: dom_id(execution, :full_pane), role: "tabpane", aria: { labelledby: dom_id(execution, :full) }, tabindex: 0 do %>
59
+ <div class="small">
60
+ <code class="text-wrap text-break m-0 text-secondary-emphasis">
61
+ <%= safe_join(execution.error_backtrace, tag.br) %>
62
+ </code>
63
+ </div>
64
+ <% end %>
65
+ <% end %>
66
+ <% end %>
41
67
  <% end %>
42
68
  <% end %>
43
69
  <%= render 'good_job/custom_execution_details', execution: execution, job: @job %>
@@ -25,7 +25,7 @@
25
25
  </button>
26
26
  <ul class="dropdown-menu" aria-labelledby="destroy-dropdown-toggle">
27
27
  <li>
28
- <%= form.button type: 'submit', name: 'mass_action', value: 'destroy', class: 'btn', title: t(".actions.destroy_all"), data: { confirm: t(".actions.confirm_destroy_all"), disable: true } do %>
28
+ <%= form.button type: 'submit', name: 'mass_action', value: 'destroy', class: 'btn dropdown-item', title: t(".actions.destroy_all"), data: { confirm: t(".actions.confirm_destroy_all"), disable: true } do %>
29
29
  <span class="me-1"><%= render_icon "trash" %></span> <%=t "good_job.actions.destroy" %>
30
30
  <% end %>
31
31
  </li>
@@ -68,7 +68,7 @@
68
68
  <%= tag.h5 tag.code(link_to(job.display_name, job_path(job), class: "text-reset text-decoration-none")), class: "text-reset mb-0" %>
69
69
  <% if job.error %>
70
70
  <div class="mt-1 small">
71
- <strong class="small"><%=t "good_job.shared.error" %>:</strong>
71
+ <strong class="small text-danger"><%=t "good_job.shared.error" %>:</strong>
72
72
  <code class="text-wrap text-break m-0 text-secondary-emphasis"><%= job.error %></code>
73
73
  </div>
74
74
  <% end %>
@@ -124,6 +124,8 @@ de:
124
124
  discard:
125
125
  notice: Auftrag wurde verworfen
126
126
  executions:
127
+ application_trace: Application Trace
128
+ full_trace: Full Trace
127
129
  in_queue: in der Warteschlange
128
130
  runtime: Laufzeit
129
131
  title: Hinrichtungen
@@ -124,6 +124,8 @@ en:
124
124
  discard:
125
125
  notice: Job has been discarded
126
126
  executions:
127
+ application_trace: Application Trace
128
+ full_trace: Full Trace
127
129
  in_queue: in queue
128
130
  runtime: runtime
129
131
  title: Executions
@@ -124,6 +124,8 @@ es:
124
124
  discard:
125
125
  notice: La tarea ha sido descartada
126
126
  executions:
127
+ application_trace: Application Trace
128
+ full_trace: Full Trace
127
129
  in_queue: en cola
128
130
  runtime: en ejecución
129
131
  title: Ejecuciones
@@ -124,6 +124,8 @@ fr:
124
124
  discard:
125
125
  notice: Le job a été mis au rebut
126
126
  executions:
127
+ application_trace: Application Trace
128
+ full_trace: Full Trace
127
129
  in_queue: Dans la file d'attente
128
130
  runtime: Durée
129
131
  title: Exécutions
@@ -124,6 +124,8 @@ it:
124
124
  discard:
125
125
  notice: Il job è stato scartato
126
126
  executions:
127
+ application_trace: Application Trace
128
+ full_trace: Full Trace
127
129
  in_queue: in coda
128
130
  runtime: tempo di esecuzione
129
131
  title: Esecuzioni
@@ -124,6 +124,8 @@ ja:
124
124
  discard:
125
125
  notice: ジョブが破棄されました
126
126
  executions:
127
+ application_trace: Application Trace
128
+ full_trace: Full Trace
127
129
  in_queue: 待機中
128
130
  runtime: 実行時間
129
131
  title: 実行
@@ -124,6 +124,8 @@ ko:
124
124
  discard:
125
125
  notice: 작업이 폐기되었습니다.
126
126
  executions:
127
+ application_trace: Application Trace
128
+ full_trace: Full Trace
127
129
  in_queue: 대기 중
128
130
  runtime: 실행 시간
129
131
  title: 실행
@@ -33,7 +33,7 @@ nl:
33
33
  no_batches_found: Geen batches gevonden.
34
34
  cron_entries:
35
35
  actions:
36
- confirm_disable: Weet u zeker dat u deze cron-vermelding wilt uitschakelen?
36
+ confirm_disable: Weet u zekerf dat u deze cron-vermelding wilt uitschakelen?
37
37
  confirm_enable: Weet u zeker dat u deze cron-invoer wilt inschakelen?
38
38
  confirm_enqueue: Weet u zeker dat u deze cron-vermelding in de wachtrij wilt plaatsen?
39
39
  disable: Schakel cron-invoer uit
@@ -124,6 +124,8 @@ nl:
124
124
  discard:
125
125
  notice: Job is weggegooid
126
126
  executions:
127
+ application_trace: Application Trace
128
+ full_trace: Full Trace
127
129
  in_queue: in de wachtrij
128
130
  runtime: looptijd
129
131
  title: Executies
@@ -124,6 +124,8 @@ pt-BR:
124
124
  discard:
125
125
  notice: A tarefa foi descartada.
126
126
  executions:
127
+ application_trace: Application Trace
128
+ full_trace: Full Trace
127
129
  in_queue: na fila
128
130
  runtime: tempo de execução
129
131
  title: Execuções
@@ -148,6 +148,8 @@ ru:
148
148
  discard:
149
149
  notice: Задание было отменено
150
150
  executions:
151
+ application_trace: Application Trace
152
+ full_trace: Full Trace
151
153
  in_queue: в очереди
152
154
  runtime: время выполнения
153
155
  title: Выполнение заданий
@@ -124,6 +124,8 @@ tr:
124
124
  discard:
125
125
  notice: İş İptal Edildi
126
126
  executions:
127
+ application_trace: Application Trace
128
+ full_trace: Full Trace
127
129
  in_queue: sırada
128
130
  runtime: çalışma süresi
129
131
  title: İşlemler
@@ -148,6 +148,8 @@ uk:
148
148
  discard:
149
149
  notice: Завдання було відхилено
150
150
  executions:
151
+ application_trace: Application Trace
152
+ full_trace: Full Trace
151
153
  in_queue: у черзі
152
154
  runtime: час виконання
153
155
  title: Виконання
@@ -57,6 +57,7 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
57
57
  t.datetime :finished_at
58
58
  t.text :error
59
59
  t.integer :error_event, limit: 2
60
+ t.text :error_backtrace, array: true
60
61
  end
61
62
 
62
63
  create_table :good_job_processes, id: :uuid do |t|
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateGoodJobExecutionErrorBacktrace < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ reversible do |dir|
6
+ dir.up do
7
+ # Ensure this incremental update migration is idempotent
8
+ # with monolithic install migration.
9
+ return if connection.column_exists?(:good_job_executions, :error_backtrace)
10
+ end
11
+ end
12
+
13
+ add_column :good_job_executions, :error_backtrace, :text, array: true
14
+ end
15
+ end
@@ -96,7 +96,7 @@ module GoodJob
96
96
 
97
97
  query = DiscreteExecution.joins(:job)
98
98
  .where(GoodJob::Job.table_name => { concurrency_key: key })
99
- .where(DiscreteExecution.arel_table[:created_at].gt(throttle_period.ago))
99
+ .where(DiscreteExecution.arel_table[:created_at].gt(DiscreteExecution.bind_value('created_at', throttle_period.ago, ActiveRecord::Type::DateTime)))
100
100
  allowed_active_job_ids = query.where(error: nil).or(query.where.not(error: "GoodJob::ActiveJobExtensions::Concurrency::ThrottleExceededError: GoodJob::ActiveJobExtensions::Concurrency::ThrottleExceededError"))
101
101
  .order(created_at: :asc)
102
102
  .limit(throttle_limit)
@@ -9,7 +9,7 @@ module GoodJob
9
9
  # @example
10
10
  # # Include the concern to your job class:
11
11
  # class MyJob < ApplicationJob
12
- # include GoodJob::ActiveJobExtensions::Notify
12
+ # include GoodJob::ActiveJobExtensions::NotifyOptions
13
13
  # self.good_job_notify = false
14
14
  # end
15
15
  #
@@ -12,7 +12,7 @@ module GoodJob
12
12
  cattr_reader :instances, default: Concurrent::Array.new, instance_reader: false
13
13
 
14
14
  # @param configuration [GoodJob::Configuration] Configuration to use for this capsule.
15
- def initialize(configuration: GoodJob.configuration)
15
+ def initialize(configuration: nil)
16
16
  @configuration = configuration
17
17
  @startable = true
18
18
  @started_at = nil
@@ -30,13 +30,13 @@ module GoodJob
30
30
  return unless startable?(force: force)
31
31
 
32
32
  @shared_executor = GoodJob::SharedExecutor.new
33
- @notifier = GoodJob::Notifier.new(enable_listening: @configuration.enable_listen_notify, executor: @shared_executor.executor)
34
- @poller = GoodJob::Poller.new(poll_interval: @configuration.poll_interval)
35
- @multi_scheduler = GoodJob::MultiScheduler.from_configuration(@configuration, warm_cache_on_initialize: true)
33
+ @notifier = GoodJob::Notifier.new(enable_listening: configuration.enable_listen_notify, executor: @shared_executor.executor)
34
+ @poller = GoodJob::Poller.new(poll_interval: configuration.poll_interval)
35
+ @multi_scheduler = GoodJob::MultiScheduler.from_configuration(configuration, warm_cache_on_initialize: true)
36
36
  @notifier.recipients.push([@multi_scheduler, :create_thread])
37
37
  @poller.recipients.push(-> { @multi_scheduler.create_thread({ fanout: true }) })
38
38
 
39
- @cron_manager = GoodJob::CronManager.new(@configuration.cron_entries, start_on_initialize: true, executor: @shared_executor.executor) if @configuration.enable_cron?
39
+ @cron_manager = GoodJob::CronManager.new(configuration.cron_entries, start_on_initialize: true, executor: @shared_executor.executor) if configuration.enable_cron?
40
40
 
41
41
  @startable = false
42
42
  @started_at = Time.current
@@ -51,7 +51,7 @@ module GoodJob
51
51
  # * +nil+ will trigger a shutdown but not wait for it to complete.
52
52
  # @return [void]
53
53
  def shutdown(timeout: NONE)
54
- timeout = @configuration.shutdown_timeout if timeout == NONE
54
+ timeout = configuration.shutdown_timeout if timeout == NONE
55
55
  GoodJob._shutdown_all([@shared_executor, @notifier, @poller, @multi_scheduler, @cron_manager].compact, timeout: timeout)
56
56
  @startable = false
57
57
  @started_at = nil
@@ -101,6 +101,10 @@ module GoodJob
101
101
 
102
102
  private
103
103
 
104
+ def configuration
105
+ @configuration || GoodJob.configuration
106
+ end
107
+
104
108
  def startable?(force: false)
105
109
  !@started_at && (@startable || force)
106
110
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module GoodJob
4
4
  # GoodJob gem version.
5
- VERSION = '3.27.4'
5
+ VERSION = '3.28.0'
6
6
 
7
7
  # GoodJob version as Gem::Version object
8
8
  GEM_VERSION = Gem::Version.new(VERSION)
data/lib/good_job.rb CHANGED
@@ -203,7 +203,7 @@ module GoodJob
203
203
  deleted_batches_count = 0
204
204
  deleted_discrete_executions_count = 0
205
205
 
206
- jobs_query = GoodJob::Job.where('finished_at <= ?', timestamp).order(finished_at: :asc).limit(in_batches_of)
206
+ jobs_query = GoodJob::Job.finished_before(timestamp).order(finished_at: :asc).limit(in_batches_of)
207
207
  jobs_query = jobs_query.succeeded unless include_discarded
208
208
  loop do
209
209
  active_job_ids = jobs_query.pluck(:active_job_id)
@@ -219,7 +219,7 @@ module GoodJob
219
219
  end
220
220
 
221
221
  if GoodJob::BatchRecord.migrated?
222
- batches_query = GoodJob::BatchRecord.where('finished_at <= ?', timestamp).limit(in_batches_of)
222
+ batches_query = GoodJob::BatchRecord.finished_before(timestamp).limit(in_batches_of)
223
223
  batches_query = batches_query.succeeded unless include_discarded
224
224
  loop do
225
225
  deleted = batches_query.delete_all
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.27.4
4
+ version: 3.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-04-04 00:00:00.000000000 Z
11
+ date: 2024-04-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -94,20 +94,6 @@ dependencies:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
96
  version: 0.14.1
97
- - !ruby/object:Gem::Dependency
98
- name: benchmark-ips
99
- requirement: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - ">="
102
- - !ruby/object:Gem::Version
103
- version: '0'
104
- type: :development
105
- prerelease: false
106
- version_requirements: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - ">="
109
- - !ruby/object:Gem::Version
110
- version: '0'
111
97
  - !ruby/object:Gem::Dependency
112
98
  name: capybara
113
99
  requirement: !ruby/object:Gem::Requirement
@@ -150,20 +136,6 @@ dependencies:
150
136
  - - ">="
151
137
  - !ruby/object:Gem::Version
152
138
  version: '0'
153
- - !ruby/object:Gem::Dependency
154
- name: pry-rails
155
- requirement: !ruby/object:Gem::Requirement
156
- requirements:
157
- - - ">="
158
- - !ruby/object:Gem::Version
159
- version: '0'
160
- type: :development
161
- prerelease: false
162
- version_requirements: !ruby/object:Gem::Requirement
163
- requirements:
164
- - - ">="
165
- - !ruby/object:Gem::Version
166
- version: '0'
167
139
  - !ruby/object:Gem::Dependency
168
140
  name: puma
169
141
  requirement: !ruby/object:Gem::Requirement
@@ -385,6 +357,7 @@ files:
385
357
  - lib/generators/good_job/templates/update/migrations/07_recreate_good_job_cron_indexes_with_conditional.rb.erb
386
358
  - lib/generators/good_job/templates/update/migrations/08_create_good_job_labels.rb.erb
387
359
  - lib/generators/good_job/templates/update/migrations/09_create_good_job_labels_index.rb.erb
360
+ - lib/generators/good_job/templates/update/migrations/10_create_good_job_execution_error_backtrace.rb.erb
388
361
  - lib/generators/good_job/templates/update/migrations/10_remove_good_job_active_id_index.rb.erb
389
362
  - lib/generators/good_job/templates/update/migrations/11_create_index_good_job_jobs_for_candidate_lookup.rb.erb
390
363
  - lib/generators/good_job/update_generator.rb