good_job 1.2.0 → 1.2.5

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -2
  3. data/README.md +280 -152
  4. data/engine/app/controllers/good_job/active_jobs_controller.rb +8 -0
  5. data/engine/app/controllers/good_job/base_controller.rb +5 -0
  6. data/engine/app/controllers/good_job/dashboards_controller.rb +50 -0
  7. data/engine/app/helpers/good_job/application_helper.rb +4 -0
  8. data/engine/app/views/assets/_style.css.erb +16 -0
  9. data/engine/app/views/good_job/active_jobs/show.html.erb +1 -0
  10. data/engine/app/views/good_job/dashboards/index.html.erb +19 -0
  11. data/engine/app/views/layouts/good_job/base.html.erb +50 -0
  12. data/engine/app/views/shared/_chart.erb +51 -0
  13. data/engine/app/views/shared/_jobs_table.erb +26 -0
  14. data/engine/app/views/vendor/bootstrap/_bootstrap-native.js.erb +1662 -0
  15. data/engine/app/views/vendor/bootstrap/_bootstrap.css.erb +10258 -0
  16. data/engine/app/views/vendor/chartist/_chartist.css.erb +613 -0
  17. data/engine/app/views/vendor/chartist/_chartist.js.erb +4516 -0
  18. data/engine/config/routes.rb +4 -0
  19. data/engine/lib/good_job/engine.rb +5 -0
  20. data/lib/active_job/queue_adapters/good_job_adapter.rb +3 -2
  21. data/lib/generators/good_job/install_generator.rb +8 -0
  22. data/lib/good_job.rb +40 -25
  23. data/lib/good_job/adapter.rb +44 -4
  24. data/lib/good_job/cli.rb +66 -13
  25. data/lib/good_job/configuration.rb +52 -0
  26. data/lib/good_job/current_execution.rb +2 -0
  27. data/lib/good_job/job.rb +118 -21
  28. data/lib/good_job/lockable.rb +125 -14
  29. data/lib/good_job/log_subscriber.rb +70 -4
  30. data/lib/good_job/multi_scheduler.rb +6 -0
  31. data/lib/good_job/notifier.rb +64 -27
  32. data/lib/good_job/performer.rb +38 -0
  33. data/lib/good_job/railtie.rb +1 -0
  34. data/lib/good_job/scheduler.rb +34 -20
  35. data/lib/good_job/version.rb +2 -1
  36. metadata +163 -7
  37. data/lib/good_job/pg_locks.rb +0 -21
@@ -1,3 +1,5 @@
1
+ require 'active_support/core_ext/module/attribute_accessors_per_thread'
2
+
1
3
  module GoodJob
2
4
  # Thread-local attributes for passing values from Instrumentation.
3
5
  # (Cannot use ActiveSupport::CurrentAttributes because ActiveJob resets it)
@@ -1,14 +1,32 @@
1
1
  module GoodJob
2
+ #
3
+ # Represents a request to perform an +ActiveJob+ job.
4
+ #
2
5
  class Job < ActiveRecord::Base
3
6
  include Lockable
4
7
 
8
+ # Raised if something attempts to execute a previously completed Job again.
5
9
  PreviouslyPerformedError = Class.new(StandardError)
6
10
 
11
+ # ActiveJob jobs without a +queue_name+ attribute are placed on this queue.
7
12
  DEFAULT_QUEUE_NAME = 'default'.freeze
13
+ # ActiveJob jobs without a +priority+ attribute are given this priority.
8
14
  DEFAULT_PRIORITY = 0
9
15
 
10
16
  self.table_name = 'good_jobs'.freeze
11
17
 
18
+ # Parse a string representing a group of queues into a more readable data
19
+ # structure.
20
+ # @return [Hash]
21
+ # How to match a given queue. It can have the following keys and values:
22
+ # - +{ all: true }+ indicates that all queues match.
23
+ # - +{ exclude: Array<String> }+ indicates the listed queue names should
24
+ # not match.
25
+ # - +{ include: Array<String> }+ indicates the listed queue names should
26
+ # match.
27
+ # @example
28
+ # GoodJob::Job.queue_parser('-queue1,queue2')
29
+ # => { exclude: [ 'queue1', 'queue2' ] }
12
30
  def self.queue_parser(string)
13
31
  string = string.presence || '*'
14
32
 
@@ -28,6 +46,10 @@ module GoodJob
28
46
  end
29
47
  end
30
48
 
49
+ # Get Jobs that have not yet been completed.
50
+ # @!method unfinished
51
+ # @!scope class
52
+ # @return [ActiveRecord::Relation]
31
53
  scope :unfinished, (lambda do
32
54
  if column_names.include?('finished_at')
33
55
  where(finished_at: nil)
@@ -36,9 +58,42 @@ module GoodJob
36
58
  nil
37
59
  end
38
60
  end)
61
+
62
+ # Get Jobs that are not scheduled for a later time than now (i.e. jobs that
63
+ # are not scheduled or scheduled for earlier than the current time).
64
+ # @!method only_scheduled
65
+ # @!scope class
66
+ # @return [ActiveRecord::Relation]
39
67
  scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(Time.current)).or(where(scheduled_at: nil)) }
68
+
69
+ # Order jobs by priority (highest priority first).
70
+ # @!method priority_ordered
71
+ # @!scope class
72
+ # @return [ActiveRecord::Relation]
40
73
  scope :priority_ordered, -> { order('priority DESC NULLS LAST') }
74
+
75
+ # Get Jobs were completed before the given timestamp. If no timestamp is
76
+ # provided, get all jobs that have been completed. By default, GoodJob
77
+ # deletes jobs after they are completed and this will find no jobs.
78
+ # However, if you have changed {GoodJob.preserve_job_records}, this may
79
+ # find completed Jobs.
80
+ # @!method finished(timestamp = nil)
81
+ # @!scope class
82
+ # @param timestamp (Float)
83
+ # Get jobs that finished before this time (in epoch time).
84
+ # @return [ActiveRecord::Relation]
41
85
  scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(timestamp)) : where.not(finished_at: nil) }
86
+
87
+ # Get Jobs on queues that match the given queue string.
88
+ # @!method queue_string(string)
89
+ # @!scope class
90
+ # @param string [String]
91
+ # A string expression describing what queues to select. See
92
+ # {Job.queue_parser} or
93
+ # {file:README.md#optimize-queues-threads-and-processes} for more details
94
+ # on the format of the string. Note this only handles individual
95
+ # semicolon-separated segments of that string format.
96
+ # @return [ActiveRecord::Relation]
42
97
  scope :queue_string, (lambda do |string|
43
98
  parsed = queue_parser(string)
44
99
 
@@ -51,6 +106,31 @@ module GoodJob
51
106
  end
52
107
  end)
53
108
 
109
+ # Get Jobs in display order with optional keyset pagination.
110
+ # @!method display_all(after_scheduled_at: nil, after_id: nil)
111
+ # @!scope class
112
+ # @param after_scheduled_at [DateTime, String, nil]
113
+ # Display records scheduled after this time for keyset pagination
114
+ # @param after_id [Numeric, String, nil]
115
+ # Display records after this ID for keyset pagination
116
+ # @return [ActiveRecord::Relation]
117
+ scope :display_all, (lambda do |after_scheduled_at: nil, after_id: nil|
118
+ query = order(Arel.sql('COALESCE(scheduled_at, created_at) DESC, id DESC'))
119
+ if after_scheduled_at.present? && after_id.present?
120
+ 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)
121
+ elsif after_scheduled_at.present?
122
+ query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at)) < (:after_scheduled_at)'), after_scheduled_at: after_scheduled_at)
123
+ end
124
+ query
125
+ end)
126
+
127
+ # Finds the next eligible Job, acquire an advisory lock related to it, and
128
+ # executes the job.
129
+ # @return [Array<(GoodJob::Job, Object, Exception)>, nil]
130
+ # If a job was executed, returns an array with the {Job} record, the
131
+ # return value for the job's +#perform+ method, and the exception the job
132
+ # raised, if any (if the job raised, then the second array entry will be
133
+ # +nil+). If there were no jobs to execute, returns +nil+.
54
134
  def self.perform_with_advisory_lock
55
135
  good_job = nil
56
136
  result = nil
@@ -58,7 +138,8 @@ module GoodJob
58
138
 
59
139
  unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
60
140
  good_job = good_jobs.first
61
- break unless good_job
141
+ # TODO: Determine why some records are fetched without an advisory lock at all
142
+ break unless good_job&.owns_advisory_lock?
62
143
 
63
144
  result, error = good_job.perform
64
145
  end
@@ -66,6 +147,15 @@ module GoodJob
66
147
  [good_job, result, error] if good_job
67
148
  end
68
149
 
150
+ # Places an ActiveJob job on a queue by creating a new {Job} record.
151
+ # @param active_job [ActiveJob::Base]
152
+ # The job to enqueue.
153
+ # @param scheduled_at [Float]
154
+ # Epoch timestamp when the job should be executed.
155
+ # @param create_with_advisory_lock [Boolean]
156
+ # Whether to establish a lock on the {Job} record after it is created.
157
+ # @return [Job]
158
+ # The new {Job} instance representing the queued ActiveJob job.
69
159
  def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
70
160
  good_job = nil
71
161
  ActiveSupport::Notifications.instrument("enqueue_job.good_job", { active_job: active_job, scheduled_at: scheduled_at, create_with_advisory_lock: create_with_advisory_lock }) do |instrument_payload|
@@ -86,32 +176,25 @@ module GoodJob
86
176
  good_job
87
177
  end
88
178
 
89
- def perform(destroy_after: !GoodJob.preserve_job_records, reperform_on_standard_error: GoodJob.reperform_jobs_on_standard_error)
179
+ # Execute the ActiveJob job this {Job} represents.
180
+ # @return [Array<(Object, Exception)>]
181
+ # An array of the return value of the job's +#perform+ method and the
182
+ # exception raised by the job, if any. If the job completed successfully,
183
+ # the second array entry (the exception) will be +nil+ and vice versa.
184
+ def perform
90
185
  raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
91
186
 
92
187
  GoodJob::CurrentExecution.reset
93
- result = nil
94
- rescued_error = nil
95
- error = nil
96
188
 
97
189
  self.performed_at = Time.current
98
- save! unless destroy_after
190
+ save! if GoodJob.preserve_job_records
99
191
 
100
- params = serialized_params.merge(
101
- "provider_job_id" => id
102
- )
103
-
104
- begin
105
- ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, process_id: GoodJob::CurrentExecution.process_id, thread_name: GoodJob::CurrentExecution.thread_name }) do
106
- result = ActiveJob::Base.execute(params)
107
- end
108
- rescue StandardError => e
109
- rescued_error = e
110
- end
192
+ result, rescued_error = execute
111
193
 
112
194
  retry_or_discard_error = GoodJob::CurrentExecution.error_on_retry ||
113
195
  GoodJob::CurrentExecution.error_on_discard
114
196
 
197
+ error = nil
115
198
  if rescued_error
116
199
  error = rescued_error
117
200
  elsif result.is_a?(Exception)
@@ -124,19 +207,33 @@ module GoodJob
124
207
  error_message = "#{error.class}: #{error.message}" if error
125
208
  self.error = error_message
126
209
 
127
- if rescued_error && reperform_on_standard_error
210
+ if rescued_error && GoodJob.reperform_jobs_on_standard_error
128
211
  save!
129
212
  else
130
213
  self.finished_at = Time.current
131
214
 
132
- if destroy_after
133
- destroy!
134
- else
215
+ if GoodJob.preserve_job_records
135
216
  save!
217
+ else
218
+ destroy!
136
219
  end
137
220
  end
138
221
 
139
222
  [result, error]
140
223
  end
224
+
225
+ private
226
+
227
+ def execute
228
+ params = serialized_params.merge(
229
+ "provider_job_id" => id
230
+ )
231
+
232
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, process_id: GoodJob::CurrentExecution.process_id, thread_name: GoodJob::CurrentExecution.thread_name }) do
233
+ [ActiveJob::Base.execute(params), nil]
234
+ end
235
+ rescue StandardError => e
236
+ [nil, e]
237
+ end
141
238
  end
142
239
  end
@@ -1,10 +1,33 @@
1
1
  module GoodJob
2
+ #
3
+ # Adds Postgres advisory locking capabilities to an ActiveRecord record.
4
+ # For details on advisory locks, see the Postgres documentation:
5
+ # - {https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS Advisory Locks Overview}
6
+ # - {https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS Advisory Locks Functions}
7
+ #
8
+ # @example Add this concern to a +MyRecord+ class:
9
+ # class MyRecord < ActiveRecord::Base
10
+ # include Lockable
11
+ #
12
+ # def my_method
13
+ # ...
14
+ # end
15
+ # end
16
+ #
2
17
  module Lockable
3
18
  extend ActiveSupport::Concern
4
19
 
20
+ # Indicates an advisory lock is already held on a record by another
21
+ # database session.
5
22
  RecordAlreadyAdvisoryLockedError = Class.new(StandardError)
6
23
 
7
24
  included do
25
+ # Attempt to acquire an advisory lock on the selected records and
26
+ # return only those records for which a lock could be acquired.
27
+ # @!method advisory_lock
28
+ # @!scope class
29
+ # @return [ActiveRecord::Relation]
30
+ # A relation selecting only the records that were locked.
8
31
  scope :advisory_lock, (lambda do
9
32
  original_query = self
10
33
 
@@ -13,35 +36,92 @@ module GoodJob
13
36
 
14
37
  query = cte_table.project(cte_table[:id])
15
38
  .with(composed_cte)
16
- .where(Arel.sql(sanitize_sql_for_conditions(["pg_try_advisory_lock(('x'||substr(md5(:table_name || \"#{cte_table.name}\".\"#{primary_key}\"::text), 1, 16))::bit(64)::bigint)", { table_name: table_name }])))
39
+ .where(Arel.sql(sanitize_sql_for_conditions(["pg_try_advisory_lock(('x' || substr(md5(:table_name || #{connection.quote_table_name(cte_table.name)}.#{quoted_primary_key}::text), 1, 16))::bit(64)::bigint)", { table_name: table_name }])))
17
40
 
18
41
  limit = original_query.arel.ast.limit
19
42
  query.limit = limit.value if limit.present?
20
43
 
21
- unscoped.where(arel_table[:id].in(query)).merge(original_query.only(:order))
44
+ unscoped.where(arel_table[primary_key].in(query)).merge(original_query.only(:order))
22
45
  end)
23
46
 
47
+ # Joins the current query with Postgres's +pg_locks+ table (it provides
48
+ # data about existing locks) such that each row in the main query joins
49
+ # to all the advisory locks associated with that row.
50
+ #
51
+ # For details on +pg_locks+, see
52
+ # {https://www.postgresql.org/docs/current/view-pg-locks.html}.
53
+ # @!method joins_advisory_locks
54
+ # @!scope class
55
+ # @return [ActiveRecord::Relation]
56
+ # @example Get the records that have a session awaiting a lock:
57
+ # MyLockableRecord.joins_advisory_locks.where("pg_locks.granted = ?", false)
24
58
  scope :joins_advisory_locks, (lambda do
25
59
  join_sql = <<~SQL
26
60
  LEFT JOIN pg_locks ON pg_locks.locktype = 'advisory'
27
61
  AND pg_locks.objsubid = 1
28
- AND pg_locks.classid = ('x'||substr(md5(:table_name || "#{table_name}"."#{primary_key}"::text), 1, 16))::bit(32)::int
29
- AND pg_locks.objid = (('x'||substr(md5(:table_name || "#{table_name}"."#{primary_key}"::text), 1, 16))::bit(64) << 32)::bit(32)::int
62
+ AND pg_locks.classid = ('x' || substr(md5(:table_name || #{quoted_table_name}.#{quoted_primary_key}::text), 1, 16))::bit(32)::int
63
+ AND pg_locks.objid = (('x' || substr(md5(:table_name || #{quoted_table_name}.#{quoted_primary_key}::text), 1, 16))::bit(64) << 32)::bit(32)::int
30
64
  SQL
31
65
 
32
66
  joins(sanitize_sql_for_conditions([join_sql, { table_name: table_name }]))
33
67
  end)
34
68
 
69
+ # Find records that do not have an advisory lock on them.
70
+ # @!method advisory_unlocked
71
+ # @!scope class
72
+ # @return [ActiveRecord::Relation]
35
73
  scope :advisory_unlocked, -> { joins_advisory_locks.where(pg_locks: { locktype: nil }) }
74
+
75
+ # Find records that have an advisory lock on them.
76
+ # @!method advisory_locked
77
+ # @!scope class
78
+ # @return [ActiveRecord::Relation]
36
79
  scope :advisory_locked, -> { joins_advisory_locks.where.not(pg_locks: { locktype: nil }) }
80
+
81
+ # Find records with advisory locks owned by the current Postgres
82
+ # session/connection.
83
+ # @!method advisory_locked
84
+ # @!scope class
85
+ # @return [ActiveRecord::Relation]
37
86
  scope :owns_advisory_locked, -> { joins_advisory_locks.where('"pg_locks"."pid" = pg_backend_pid()') }
38
87
 
88
+ # @!attribute [r] create_with_advisory_lock
89
+ # @return [Boolean]
90
+ # Whether an advisory lock should be acquired in the same transaction
91
+ # that created the record.
92
+ #
93
+ # This helps prevent another thread or database session from acquiring a
94
+ # lock on the record between the time you create it and the time you
95
+ # request a lock, since other sessions will not be able to see the new
96
+ # record until the transaction that creates it is completed (at which
97
+ # point you have already acquired the lock).
98
+ #
99
+ # @example
100
+ # record = MyLockableRecord.create(create_with_advisory_lock: true)
101
+ # record.advisory_locked?
102
+ # => true
39
103
  attr_accessor :create_with_advisory_lock
40
104
 
41
105
  after_create -> { advisory_lock }, if: :create_with_advisory_lock
42
106
  end
43
107
 
44
108
  class_methods do
109
+ # Acquires an advisory lock on the selected record(s) and safely releases
110
+ # it after the passed block is completed. The block will be passed an
111
+ # array of the locked records as its first argument.
112
+ #
113
+ # Note that this will not block and wait for locks to be acquired.
114
+ # Instead, it will acquire a lock on all the selected records that it
115
+ # can (as in {Lockable.advisory_lock}) and only pass those that could be
116
+ # locked to the block.
117
+ #
118
+ # @yield [Array<Lockable>] the records that were successfully locked.
119
+ # @return [Object] the result of the block.
120
+ #
121
+ # @example Work on the first two +MyLockableRecord+ objects that could be locked:
122
+ # MyLockableRecord.order(created_at: :asc).limit(2).with_advisory_lock do |record|
123
+ # do_something_with record
124
+ # end
45
125
  def with_advisory_lock
46
126
  raise ArgumentError, "Must provide a block" unless block_given?
47
127
 
@@ -54,27 +134,51 @@ module GoodJob
54
134
  end
55
135
  end
56
136
 
137
+ # Acquires an advisory lock on this record if it is not already locked by
138
+ # another database session. Be careful to ensure you release the lock when
139
+ # you are done with {#advisory_unlock} (or {#advisory_unlock!} to release
140
+ # all remaining locks).
141
+ # @return [Boolean] whether the lock was acquired.
57
142
  def advisory_lock
58
- query = <<~SQL
59
- SELECT 1 AS one
60
- WHERE pg_try_advisory_lock(('x'||substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
143
+ where_sql = <<~SQL
144
+ pg_try_advisory_lock(('x' || substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
61
145
  SQL
62
- self.class.connection.execute(sanitize_sql_for_conditions([query, { table_name: self.class.table_name, id: send(self.class.primary_key) }])).ntuples.positive?
146
+ self.class.unscoped.where(where_sql, { table_name: self.class.table_name, id: send(self.class.primary_key) }).exists?
63
147
  end
64
148
 
149
+ # Releases an advisory lock on this record if it is locked by this database
150
+ # session. Note that advisory locks stack, so you must call
151
+ # {#advisory_unlock} and {#advisory_lock} the same number of times.
152
+ # @return [Boolean] whether the lock was released.
65
153
  def advisory_unlock
66
- query = <<~SQL
67
- SELECT 1 AS one
68
- WHERE pg_advisory_unlock(('x'||substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
154
+ where_sql = <<~SQL
155
+ pg_advisory_unlock(('x' || substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
69
156
  SQL
70
- self.class.connection.execute(sanitize_sql_for_conditions([query, { table_name: self.class.table_name, id: send(self.class.primary_key) }])).ntuples.positive?
157
+ self.class.unscoped.where(where_sql, { table_name: self.class.table_name, id: send(self.class.primary_key) }).exists?
71
158
  end
72
159
 
160
+ # Acquires an advisory lock on this record or raises
161
+ # {RecordAlreadyAdvisoryLockedError} if it is already locked by another
162
+ # database session.
163
+ # @raise [RecordAlreadyAdvisoryLockedError]
164
+ # @return [Boolean] +true+
73
165
  def advisory_lock!
74
166
  result = advisory_lock
75
167
  result || raise(RecordAlreadyAdvisoryLockedError)
76
168
  end
77
169
 
170
+ # Acquires an advisory lock on this record and safely releases it after the
171
+ # passed block is completed. If the record is locked by another database
172
+ # session, this raises {RecordAlreadyAdvisoryLockedError}.
173
+ #
174
+ # @yield Nothing
175
+ # @return [Object] The result of the block.
176
+ #
177
+ # @example
178
+ # record = MyLockableRecord.first
179
+ # record.with_advisory_lock do
180
+ # do_something_with record
181
+ # end
78
182
  def with_advisory_lock
79
183
  raise ArgumentError, "Must provide a block" unless block_given?
80
184
 
@@ -84,14 +188,21 @@ module GoodJob
84
188
  advisory_unlock unless $ERROR_INFO.is_a? RecordAlreadyAdvisoryLockedError
85
189
  end
86
190
 
191
+ # Tests whether this record has an advisory lock on it.
192
+ # @return [Boolean]
87
193
  def advisory_locked?
88
- self.class.advisory_locked.where(id: send(self.class.primary_key)).any?
194
+ self.class.unscoped.advisory_locked.where(id: send(self.class.primary_key)).exists?
89
195
  end
90
196
 
197
+ # Tests whether this record is locked by the current database session.
198
+ # @return [Boolean]
91
199
  def owns_advisory_lock?
92
- self.class.owns_advisory_locked.where(id: send(self.class.primary_key)).any?
200
+ self.class.unscoped.owns_advisory_locked.where(id: send(self.class.primary_key)).exists?
93
201
  end
94
202
 
203
+ # Releases all advisory locks on the record that are held by the current
204
+ # database session.
205
+ # @return [void]
95
206
  def advisory_unlock!
96
207
  advisory_unlock while advisory_locked?
97
208
  end
@@ -1,6 +1,22 @@
1
1
  module GoodJob
2
+ #
3
+ # Listens to GoodJob notifications and logs them.
4
+ #
5
+ # Each method corresponds to the name of a notification. For example, when
6
+ # the {Scheduler} shuts down, it sends a notification named
7
+ # +"scheduler_shutdown.good_job"+ and the {#scheduler_shutdown} method will
8
+ # be called here. See the
9
+ # {https://api.rubyonrails.org/classes/ActiveSupport/LogSubscriber.html ActiveSupport::LogSubscriber}
10
+ # documentation for more.
11
+ #
2
12
  class LogSubscriber < ActiveSupport::LogSubscriber
13
+ # @!group Notifications
14
+
15
+ # @!macro notification_responder
16
+ # Responds to the +$0.good_job+ notification.
17
+ # @return [void]
3
18
  def create(event)
19
+ # FIXME: This method does not match any good_job notifications.
4
20
  good_job = event.payload[:good_job]
5
21
 
6
22
  debug do
@@ -8,7 +24,9 @@ module GoodJob
8
24
  end
9
25
  end
10
26
 
27
+ # @macro notification_responder
11
28
  def timer_task_finished(event)
29
+ # FIXME: This method does not match any good_job notifications.
12
30
  exception = event.payload[:error]
13
31
  return unless exception
14
32
 
@@ -17,7 +35,9 @@ module GoodJob
17
35
  end
18
36
  end
19
37
 
38
+ # @macro notification_responder
20
39
  def job_finished(event)
40
+ # FIXME: This method does not match any good_job notifications.
21
41
  exception = event.payload[:error]
22
42
  return unless exception
23
43
 
@@ -26,6 +46,7 @@ module GoodJob
26
46
  end
27
47
  end
28
48
 
49
+ # @macro notification_responder
29
50
  def scheduler_create_pools(event)
30
51
  max_threads = event.payload[:max_threads]
31
52
  poll_interval = event.payload[:poll_interval]
@@ -37,6 +58,7 @@ module GoodJob
37
58
  end
38
59
  end
39
60
 
61
+ # @macro notification_responder
40
62
  def scheduler_shutdown_start(event)
41
63
  process_id = event.payload[:process_id]
42
64
 
@@ -45,6 +67,7 @@ module GoodJob
45
67
  end
46
68
  end
47
69
 
70
+ # @macro notification_responder
48
71
  def scheduler_shutdown(event)
49
72
  process_id = event.payload[:process_id]
50
73
 
@@ -53,6 +76,7 @@ module GoodJob
53
76
  end
54
77
  end
55
78
 
79
+ # @macro notification_responder
56
80
  def scheduler_restart_pools(event)
57
81
  process_id = event.payload[:process_id]
58
82
 
@@ -61,6 +85,7 @@ module GoodJob
61
85
  end
62
86
  end
63
87
 
88
+ # @macro notification_responder
64
89
  def perform_job(event)
65
90
  good_job = event.payload[:good_job]
66
91
  process_id = event.payload[:process_id]
@@ -71,12 +96,14 @@ module GoodJob
71
96
  end
72
97
  end
73
98
 
99
+ # @macro notification_responder
74
100
  def notifier_listen(_event)
75
101
  info do
76
102
  "Notifier subscribed with LISTEN"
77
103
  end
78
104
  end
79
105
 
106
+ # @macro notification_responder
80
107
  def notifier_notified(event)
81
108
  payload = event.payload[:payload]
82
109
 
@@ -85,6 +112,7 @@ module GoodJob
85
112
  end
86
113
  end
87
114
 
115
+ # @macro notification_responder
88
116
  def notifier_notify_error(event)
89
117
  error = event.payload[:error]
90
118
 
@@ -93,12 +121,14 @@ module GoodJob
93
121
  end
94
122
  end
95
123
 
124
+ # @macro notification_responder
96
125
  def notifier_unlisten(_event)
97
126
  info do
98
127
  "Notifier unsubscribed with UNLISTEN"
99
128
  end
100
129
  end
101
130
 
131
+ # @macro notification_responder
102
132
  def cleanup_preserved_jobs(event)
103
133
  timestamp = event.payload[:timestamp]
104
134
  deleted_records_count = event.payload[:deleted_records_count]
@@ -108,11 +138,34 @@ module GoodJob
108
138
  end
109
139
  end
110
140
 
141
+ # @!endgroup
142
+
143
+ # Get the logger associated with this {LogSubscriber} instance.
144
+ # @return [Logger]
145
+ def logger
146
+ GoodJob::LogSubscriber.logger
147
+ end
148
+
111
149
  class << self
150
+ # Tracks all loggers that {LogSubscriber} is writing to. You can write to
151
+ # multiple logs by appending to this array. After updating it, you should
152
+ # usually call {LogSubscriber.reset_logger} to make sure they are all
153
+ # written to.
154
+ #
155
+ # Defaults to {GoodJob.logger}.
156
+ # @return [Array<Logger>]
157
+ # @example Write to STDOUT and to a file:
158
+ # GoodJob::LogSubscriber.loggers << ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
159
+ # GoodJob::LogSubscriber.loggers << ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new("log/my_logs.log"))
160
+ # GoodJob::LogSubscriber.reset_logger
112
161
  def loggers
113
162
  @_loggers ||= [GoodJob.logger]
114
163
  end
115
164
 
165
+ # Represents all the loggers attached to {LogSubscriber} with a single
166
+ # logging interface. Writing to this logger is a shortcut for writing to
167
+ # each of the loggers in {LogSubscriber.loggers}.
168
+ # @return [Logger]
116
169
  def logger
117
170
  @_logger ||= begin
118
171
  logger = Logger.new(StringIO.new)
@@ -123,17 +176,22 @@ module GoodJob
123
176
  end
124
177
  end
125
178
 
179
+ # Reset {LogSubscriber.logger} and force it to rebuild a new shortcut to
180
+ # all the loggers in {LogSubscriber.loggers}. You should usually call
181
+ # this after modifying the {LogSubscriber.loggers} array.
182
+ # @return [void]
126
183
  def reset_logger
127
184
  @_logger = nil
128
185
  end
129
186
  end
130
187
 
131
- def logger
132
- GoodJob::LogSubscriber.logger
133
- end
134
-
135
188
  private
136
189
 
190
+ # Add "GoodJob" plus any specified tags to every
191
+ # {ActiveSupport::TaggedLogging} logger in {LogSubscriber.loggers}. Tags
192
+ # are only applicable inside the block passed to this method.
193
+ # @yield [void]
194
+ # @return [void]
137
195
  def tag_logger(*tags, &block)
138
196
  tags = tags.dup.unshift("GoodJob").compact
139
197
 
@@ -152,6 +210,14 @@ module GoodJob
152
210
  end.call
153
211
  end
154
212
 
213
+ # Ensure that the standard logging methods include "GoodJob" as a tag and
214
+ # that they include a second argument allowing callers to specify ad-hoc
215
+ # tags to include in the message.
216
+ #
217
+ # For example, to include the tag "ForFunsies" on an +info+ message:
218
+ #
219
+ # self.info("Some message", tags: ["ForFunsies"])
220
+ #
155
221
  %w(info debug warn error fatal unknown).each do |level|
156
222
  class_eval <<-METHOD, __FILE__, __LINE__ + 1
157
223
  def #{level}(progname = nil, tags: [], &block)