marj 5.0.0 → 6.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 450ae15f6cf892215323b4eeeccdbf8a75c2db6162b0a75378c411f6e0bdac9c
4
- data.tar.gz: 5b1fabd1000b8439e158aabc943deef507045252efe6805cd26a89629e39efe0
3
+ metadata.gz: 1fd27db3bb7a04aee0b4e6cf043e35bde5329143194584bcb03b75ef00896f5d
4
+ data.tar.gz: d89fd27053c6beeb6aeddcaac59fd1a0ddd4b2a481a4dd0f9b96b3cbbbc29bde
5
5
  SHA512:
6
- metadata.gz: 1e49dcbc43034f454fb57bf1dca72972eba67719cd8c9aa1aa43b10f91287d2bab41bb97025dbca688de9e6630f0c0d494e6607100382165f56a0f93a38fc5e4
7
- data.tar.gz: d075bd8f29864ea83be5ce0099f925f9bb527f9373940427c98cfce3545ee25a565a1fb6293023938bbd39b320ab55015b924281a31bdabb43085398551342cc
6
+ metadata.gz: 5b1304cba5e2780fa9fd24432624e37a0a1746efefa9ffaf11573875a2333b1ae9fda8f419b94f30f953c59be0896dae6b90e808b089bbac2324cbe787d2ed8d
7
+ data.tar.gz: c9b6926e5ef0cad9fc33d510eb9102d570e4ace04322cfc62c44367651a42034c7be5f2dbbf88fc851c13fc6acfc005056d342676bcd505f356696b8fa0305c1
data/README.md CHANGED
@@ -2,69 +2,88 @@
2
2
 
3
3
  A minimal database-backed ActiveJob queueing backend.
4
4
 
5
- ## Purpose
6
-
7
- To provide a database-backed ActiveJob queueing backend with as few features
8
- as possible and the minimum backend-specific API required.
9
-
10
5
  ## Quick Links
11
6
 
12
- API docs: https://gemdocs.org/gems/marj/5.0.0/ <br>
7
+ API docs: https://gemdocs.org/gems/marj/6.1.0/ <br>
13
8
  RubyGems: https://rubygems.org/gems/marj <br>
14
9
  Changelog: https://github.com/nicholasdower/marj/releases <br>
15
10
  Issues: https://github.com/nicholasdower/marj/issues <br>
16
11
  Development: https://github.com/nicholasdower/marj/blob/master/CONTRIBUTING.md
17
12
 
18
- ## Features
13
+ ## Motivation
19
14
 
20
- ### Provided
15
+ There are already several great database-backed ActiveJob queueing backends:
16
+ - [Delayed::Job](https://github.com/collectiveidea/delayed_job)
17
+ - [Que](https://github.com/que-rb/que)
18
+ - [GoodJob](https://github.com/bensheldon/good_job)
19
+ - [Solid Queue](https://github.com/basecamp/solid_queue)
21
20
 
22
- - Enqueued jobs are written to the database.
23
- - Successfully executed jobs are deleted from the database.
24
- - Failed jobs which should be retried are updated in the database.
25
- - Failed jobs which should not be retried are deleted from the database.
26
- - An method is provided to query enqueued jobs.
27
- - An method is provided to discard enqueued jobs.
28
- - An `ActiveRecord` class is provided to query the database directly.
21
+ Any of these which support your RDBMS is likely to work well for you. But you
22
+ may find them to be more featureful and complex than what you require.
23
+
24
+ Marj aims to be a minimal alternative.
29
25
 
30
- ### Not Provided
26
+ ## Goal
31
27
 
32
- - Automatic job execution
33
- - Timeouts
34
- - Concurrency controls
35
- - Observability
36
- - A user interace
28
+ To be the database-backend ActiveJob queueing backend with:
29
+ - The simplest setup
30
+ - The fewest configuration options
31
+ - The fewest features
32
+ - The fewest backend-specific APIs
33
+ - The fewest lines of code
37
34
 
38
- Note that because Marj does not automatically execute jobs, clients are
39
- responsible for retrieving and either executing or discarding jobs.
35
+ ## Features
36
+
37
+ Marj supports and has been tested with MySQL, PostgreSQL and SQLite.
38
+
39
+ It provides the following features:
40
+ - Enqueued jobs are written to the database.
41
+ - Enqueued jobs can be queried, executed and discarded.
42
+ - Executed jobs are re-enqueued or discarded, depending on the result.
43
+ - Compatibility with [Mission Control Jobs](https://github.com/basecamp/mission_control-jobs).
44
+
45
+ ## Extensions
46
+
47
+ Marj does not provide the following features by default, but each can easily
48
+ be added to your application with a few lines of code. See [Example Usage](#example-usage)
49
+ and [Extension Examples](#extension-examples) for suggestions.
50
+ - [Automatic job execution](#example-usage)
51
+ - [Concurrency protection](#concurrency-protection)
52
+ - [Job timeouts](#job-timeouts)
53
+ - [Error storage](#error-storage)
54
+ - [Discarded job retention](#discarded-job-retention)
55
+ - [Observability](#observability)
56
+ - [Multi-DB Support](#multi-db-support)
40
57
 
41
58
  ## API
42
59
 
43
- The ActiveJob API already provides methods for enqueueing and performing jobs:
60
+ Marj relies on existing ActiveJob APIs, for example:
44
61
 
45
62
  ```ruby
46
- queue_adapter.enqueue(job) # Enqueue
47
- job.enqueue # Enqueue
48
- job.perform_now # Perform
63
+ job.enqueue # Enqueues a job
64
+ job.perform_now # Executes a job
49
65
  ```
50
66
 
51
- Marj works with these existing methods and additionally extends the ActiveJob API
52
- with methods for querying and discarding jobs:
67
+ Additionally, it extends the ActiveJob API with methods required for a
68
+ minimal queueing backend implementaion:
53
69
 
54
70
  ```ruby
55
- queue_adapter.query(args) # Query
56
- SomeJob.query(args) # Query
57
- queue_adapter.discard(job) # Discard
58
- job.discard # Discard
71
+ SomeJob.query(args) # Queries for enqeueued jobs
72
+ job.discard # Runs discard callbacks and, by default, deletes the job
73
+ job.delete # Deletes the job
59
74
  ```
60
75
 
76
+ ## Requirements
77
+
78
+ - `activejob >= 7.1`
79
+ - `activerecord >= 7.1`
80
+
61
81
  ## Setup
62
82
 
63
83
  ### 1. Install
64
84
 
65
85
  ```shell
66
- bundle add activejob activerecord marj # via Bundler
67
- gem install activejob activerecord marj # or globally
86
+ bundle add marj
68
87
  ```
69
88
 
70
89
  ### 2. Create the database table
@@ -85,6 +104,8 @@ class CreateJobs < ActiveRecord::Migration[7.1]
85
104
  table.string :timezone, null: false
86
105
  end
87
106
 
107
+ add_index :jobs, %i[job_class]
108
+ add_index :jobs, %i[queue_name]
88
109
  add_index :jobs, %i[enqueued_at]
89
110
  add_index :jobs, %i[scheduled_at]
90
111
  add_index :jobs, %i[priority scheduled_at enqueued_at]
@@ -101,29 +122,26 @@ end
101
122
  ```ruby
102
123
  require 'marj'
103
124
 
125
+ # Choose one of the following:
104
126
  Rails.configuration.active_job.queue_adapter = :marj # Globally, with Rails
105
127
  ActiveJob::Base.queue_adapter = :marj # Globally, without Rails
106
128
  SomeJob.queue_adapter = :marj # Single job
107
129
  ```
108
130
 
109
- ### 4. Include the Marj module (optional)
131
+ ### 4. Optionally, include the Marj module
110
132
 
111
- By default, jobs can be queried and discarded via the `MarjAdapter` or the
112
- `Marj` module:
133
+ Without any additional setup, jobs can be queried and discarded via the `Marj`
134
+ module:
113
135
 
114
136
  ```ruby
115
137
  Marj.query(:all)
116
- ActiveJob::Base.queue_adapter.query(:all)
117
138
  Marj.discard(job)
118
- ActiveJob::Base.queue_adapter.discard(job)
119
139
  ```
120
140
 
121
141
  But it is also convenient to query or discard via job classes:
122
142
 
123
143
  ```ruby
124
- ApplicationJob.query(:all)
125
144
  SomeJob.query(:all)
126
- ApplicationJob.discard(job)
127
145
  SomeJob.discard(job)
128
146
  job.discard
129
147
  ```
@@ -131,11 +149,9 @@ job.discard
131
149
  In order to enable this functionality, you must include the `Marj` module:
132
150
 
133
151
  ```ruby
134
- class ApplicationJob < ActiveJob::Base
152
+ class SomeJob < ActiveJob::Base
135
153
  include Marj
136
- end
137
154
 
138
- class SomeJob < ApplicationJob
139
155
  def perform; end
140
156
  end
141
157
  ```
@@ -143,25 +159,27 @@ end
143
159
  ## Example Usage
144
160
 
145
161
  ```ruby
146
- # Enqueue and manually run a job
162
+ # Enqueue a job
147
163
  job = SomeJob.perform_later('foo')
148
- job.perform_now
149
164
 
150
- # Retrieve and execute a job
151
- Marj.query(:due, :first).perform_now
165
+ # Query jobs
166
+ Marj.query(:all)
167
+ Marj.query(:due)
168
+ Marj.query(:due, queue: :foo)
169
+ Marj.query('8720417d-8fff-4fcf-bc16-22aaef8543d2')
170
+
171
+ # Execute a job
172
+ job.perform_now
152
173
 
153
- # Run all due jobs (single DB query)
174
+ # Execute jobs which are due (single query)
154
175
  Marj.query(:due).map(&:perform_now)
155
176
 
156
- # Run all due jobs (multiple DB queries)
177
+ # Execute jobs which are due (multiple queries)
157
178
  loop do
158
- break unless Marj.query(:due, :first)&.tap(&:perform_now)
179
+ Marj.query(:due, :first)&.tap(&:perform_now) || break
159
180
  end
160
181
 
161
- # Run all jobs in a specific queue which are due to be executed
162
- Marj.query(:due, queue: :foo).map(&:perform_now)
163
-
164
- # Run jobs as they become due
182
+ # Execute jobs as they become due
165
183
  loop do
166
184
  Marj.query(:due).each(&:perform_now) rescue logger.error($!)
167
185
  ensure
@@ -175,17 +193,54 @@ By default, jobs enqeued during tests will be written to the database. Enqueued
175
193
  jobs can be executed via:
176
194
 
177
195
  ```ruby
178
- Marj.due.perform_all
196
+ SomeJob.query(:due).map(&:perform_now)
179
197
  ```
180
198
 
181
- Alternatively, to use [ActiveJob::QueueAdapters::TestAdapter](https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/TestAdapter.html):
199
+ Alternatively, to use ActiveJob's [TestAdapter](https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/TestAdapter.html):
182
200
  ```ruby
183
201
  ActiveJob::Base.queue_adapter = :test
184
202
  ```
185
203
 
186
204
  ## Extension Examples
187
205
 
188
- ### Timeouts
206
+ Most missing features can easily be added to your application with a few lines
207
+ of code.
208
+
209
+ ### Concurrency Protection
210
+
211
+ To prevent two threads from executing the same job simultaneously, consider
212
+ moving jobs to a different queue before executing them.
213
+
214
+ ```ruby
215
+ class ApplicationJob < ActiveJob::Base
216
+ around_perform do |job, block|
217
+ if job.queue_name.start_with?('claimed')
218
+ raise "Job #{job.job_id} already claimed"
219
+ end
220
+
221
+ updated = Marj::Record
222
+ .where(job_id: job.job_id, queue_name: job.queue_name)
223
+ .update_all(
224
+ queue_name: "claimed-#{job.queue_name}",
225
+ scheduled_at: Time.now.utc
226
+ )
227
+ unless updated == 1
228
+ raise "Failed to claim job #{job.job_id}. #{updated} records updated"
229
+ end
230
+
231
+ begin
232
+ block.call
233
+ rescue StandardError
234
+ Marj::Record
235
+ .where(job_id: job.job_id, queue_name: "claimed-#{job.queue_name}")
236
+ .update_all(queue_name: job.queue_name, scheduled_at: job.scheduled_at)
237
+ raise
238
+ end
239
+ end
240
+ end
241
+ ```
242
+
243
+ ### Job Timeouts
189
244
 
190
245
  ```ruby
191
246
  class ApplicationJob < ActiveJob::Base
@@ -205,7 +260,7 @@ class ApplicationJob < ActiveJob::Base
205
260
  end
206
261
  ```
207
262
 
208
- ### Last Error
263
+ ### Error Storage
209
264
 
210
265
  ```ruby
211
266
  class AddLastErrorToJobs < ActiveRecord::Migration[7.1]
@@ -246,51 +301,77 @@ class ApplicationJob < ActiveJob::Base
246
301
  end
247
302
  ```
248
303
 
249
- ### Multiple Tables/Databases
304
+ ### Discarded Job Retention
250
305
 
251
- It is possible to create a custom record class in order to, for instance,
252
- write jobs to multiple databases/tables within a single application.
306
+ By default, discarded jobs are deleted from the database. If desired, this
307
+ behavior can be overridden, for instance to move jobs to a differnt queue:
253
308
 
254
309
  ```ruby
255
- class CreateMyJobs < ActiveRecord::Migration[7.1]
256
- def self.up
257
- create_table :my_jobs, id: :string, primary_key: :job_id do |table|
258
- table.string :job_class, null: false
259
- table.text :arguments, null: false
260
- table.string :queue_name, null: false
261
- table.integer :priority
262
- table.integer :executions, null: false
263
- table.text :exception_executions, null: false
264
- table.datetime :enqueued_at, null: false
265
- table.datetime :scheduled_at
266
- table.string :locale, null: false
267
- table.string :timezone, null: false
268
- end
310
+ ActiveJob::Base.queue_adapter = MarjAdapter.new(
311
+ discard: proc { _1.enqueue(queue: 'discarded') }
312
+ )
313
+
314
+ # To retrieve a discarded job, query the discarded queue:
315
+ job = Marj.query(:first, queue_name: 'discarded')
316
+
317
+ # To delete, use one of the following:
318
+ Marj.delete(job)
319
+ job.delete
320
+ ```
269
321
 
270
- add_index :my_jobs, %i[enqueued_at]
271
- add_index :my_jobs, %i[scheduled_at]
272
- add_index :my_jobs, %i[priority scheduled_at enqueued_at]
322
+ ### Observability
323
+
324
+ For instance with Prometheus metrics:
325
+
326
+ ```ruby
327
+ class ApplicationJob < ActiveJob::Base
328
+ counter = Prometheus::Client::Counter.new(
329
+ :job_events_total, docstring: '...', labels: [:job, :event]
330
+ )
331
+
332
+ around_enqueue do |job, block|
333
+ counter.increment(labels: { job: job.class.name, event: 'before_enqueue' })
334
+ block.call
335
+ counter.increment(labels: { job: job.class.name, event: 'after_enqueue' })
336
+ rescue Exception
337
+ counter.increment(labels: { job: job.class.name, event: 'enqueue_error' })
338
+ raise
273
339
  end
274
340
 
275
- def self.down
276
- drop_table :my_jobs
341
+ around_perform do |job, block|
342
+ counter.increment(labels: { job: job.class.name, event: 'before_perform' })
343
+ block.call
344
+ counter.increment(labels: { job: job.class.name, event: 'after_perform' })
345
+ rescue Exception => e # rubocop:disable Lint/RescueException
346
+ counter.increment(labels: { job: job.class.name, event: 'perform_error' })
347
+ raise
348
+ end
349
+
350
+ after_discard do |job|
351
+ counter.increment(labels: { job: job.class.name, event: 'after_discard' })
277
352
  end
278
353
  end
354
+ ```
355
+
356
+ ### Multi-DB Support
357
+
358
+ It is possible to create a custom record class in order to, for instance,
359
+ use a different class or table name, or write jobs to multiple
360
+ databases/tables within a single application.
361
+
362
+ Assuming you have a jobs tabled named `my_jobs`:
279
363
 
364
+ ```ruby
280
365
  class MyRecord < Marj::Record
281
366
  self.table_name = 'my_jobs'
282
367
  end
283
368
 
284
- CreateMyJobs.migrate(:up)
285
-
286
369
  class MyJob < ActiveJob::Base
287
- self.queue_adapter = MarjAdapter.new('MyRecord')
288
-
289
370
  include Marj
290
371
 
291
- def perform(msg)
292
- puts msg
293
- end
372
+ self.queue_adapter = MarjAdapter.new('MyRecord')
373
+
374
+ def perform; end
294
375
  end
295
376
 
296
377
  MyJob.perform_later('oh, hi')
@@ -378,7 +459,7 @@ job = SomeJob.new.deserialize(other_job.serialize)
378
459
  job = SomeJob.perform_later
379
460
  job = SomeJob.perform_later(args)
380
461
 
381
- # Create without enqueueing and run (only enqueued on failure if retryable)
462
+ # Create without enqueueing and run (only enqueued on retryable failure)
382
463
  SomeJob.perform_now
383
464
  SomeJob.perform_now(args)
384
465
  ```
@@ -399,7 +480,7 @@ SomeJob.perform_later(SomeJob.new(args))
399
480
  SomeJob.perform_later(args)
400
481
  SomeJob.set(options).perform_later(args)
401
482
 
402
- # After a failure during execution
483
+ # After a retryable execution failure
403
484
  SomeJob.perform_now(args)
404
485
  ActiveJob::Base.execute(SomeJob.new(args).serialize)
405
486
 
@@ -413,7 +494,7 @@ SomeJob.set(options).perform_all_later(SomeJob.new, SomeJob.new, options:)
413
494
  ### Executing Jobs
414
495
 
415
496
  ```ruby
416
- # Executed without enqueueing, enqueued on failure if retryable
497
+ # Executed without enqueueing, enqueued on retryable failure
417
498
  SomeJob.new(args).perform_now
418
499
  SomeJob.perform_now(args)
419
500
  ActiveJob::Base.execute(SomeJob.new(args).serialize)
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../marj_adapter'
4
+ require 'mission_control/jobs'
5
+ require 'mission_control/jobs/adapter'
6
+
7
+ # :nocov:
8
+ module Marj
9
+ module MissionControl
10
+ include ::MissionControl::Jobs::Adapter
11
+
12
+ def queues
13
+ record_class.group(:queue_name).count(:queue_name).map { |k, v| { name: k, size: v, active: true } }
14
+ end
15
+
16
+ def queue_size(queue_name)
17
+ record_class.where(queue_name: queue_name).count
18
+ end
19
+
20
+ def clear_queue(queue_name)
21
+ Marj::Record.where(queue_name: queue_name).delete_all
22
+ end
23
+
24
+ def pause_queue(_queue_name)
25
+ raise 'not supported: pause queue'
26
+ end
27
+
28
+ def resume_queue(_queue_name)
29
+ raise 'not supported: resume queue'
30
+ end
31
+
32
+ def queue_paused?(_queue_name)
33
+ false
34
+ end
35
+
36
+ def supported_statuses
37
+ %i[pending failed scheduled]
38
+ end
39
+
40
+ def supported_filters(_jobs_relation)
41
+ %i[queue_name job_class_name]
42
+ end
43
+
44
+ def exposes_workers?
45
+ false
46
+ end
47
+
48
+ def workers
49
+ raise 'not supported: workers'
50
+ end
51
+
52
+ def find_worker(_worker_id)
53
+ raise 'not supported: find workers'
54
+ end
55
+
56
+ def jobs_count(jobs_relation)
57
+ ar_relation(jobs_relation).count
58
+ end
59
+
60
+ def fetch_jobs(jobs_relation)
61
+ ar_relation(jobs_relation).each_with_index.map { |record, index| to_job(record, jobs_relation, index) }
62
+ end
63
+
64
+ def retry_all_jobs(jobs_relation)
65
+ ar_relation(jobs_relation).map { |record| record.to_job.perform_now }
66
+ end
67
+
68
+ def retry_job(job, _jobs_relation)
69
+ Marj::Record.find(job.job_id).to_job.perform_now
70
+ end
71
+
72
+ def discard_all_jobs(jobs_relation)
73
+ ar_relation(jobs_relation).map { |record| discard(record.to_job) }
74
+ end
75
+
76
+ def discard_job(job, _jobs_relation)
77
+ discard(Marj::Record.find(job.job_id).to_job)
78
+ end
79
+
80
+ def find_job(job_id, jobs_relation)
81
+ to_job(record_class.find_by(job_id: job_id), jobs_relation)
82
+ end
83
+
84
+ private
85
+
86
+ def ar_relation(jobs_relation)
87
+ relation = Marj::Record.all.offset(jobs_relation.offset_value).limit(jobs_relation.limit_value)
88
+ relation = relation.where.not(executions: 0) if jobs_relation.status == :failed
89
+ relation = relation.where.not(scheduled_at: nil) if jobs_relation.status == :scheduled
90
+ relation = relation.where(job_class: jobs_relation.job_class_name) if jobs_relation.job_class_name
91
+ relation = relation.where(queue_name: jobs_relation.queue_name) if jobs_relation.queue_name
92
+ relation
93
+ end
94
+
95
+ def to_job(record, jobs_relation, index = 0)
96
+ return nil unless record
97
+
98
+ job = record.to_job
99
+ job_data = job.serialize
100
+ ActiveJob::JobProxy.new(job_data).tap do |proxy|
101
+ if job.executions.positive?
102
+ proxy.last_execution_error = ActiveJob::ExecutionError.new(
103
+ error_class: Exception, message: 'unknown', backtrace: []
104
+ )
105
+ proxy.failed_at = job.enqueued_at
106
+ proxy.status = :failed
107
+ elsif job.scheduled_at
108
+ proxy.status = :scheduled
109
+ else
110
+ proxy.status = :pending
111
+ end
112
+ proxy.raw_data = job_data
113
+ proxy.position = jobs_relation.offset_value + index
114
+ proxy.arguments = job.arguments # For some reason MissionControl sets the arguments to the entire job data
115
+ end
116
+ end
117
+ end
118
+ end
119
+ # :nocov:
data/lib/marj/record.rb CHANGED
@@ -84,8 +84,8 @@ module Marj
84
84
  # - If a job succeeds, after_perform will be called.
85
85
  # - If a job fails and should be retried, enqueue will be called. This is handled by the queue adapter.
86
86
  # - If a job exceeds its max attempts, after_discard will be called.
87
- job.singleton_class.after_perform { |_j| record.destroy! }
88
- job.singleton_class.after_discard { |_j, _exception| record.destroy! }
87
+ job.singleton_class.after_perform { |_j| job.queue_adapter.delete(job) }
88
+ job.singleton_class.after_discard { |_j, _exception| job.queue_adapter.discard(job, run_callbacks: false) }
89
89
  job.singleton_class.instance_variable_set(:@record, record)
90
90
 
91
91
  job
data/lib/marj.rb CHANGED
@@ -6,13 +6,15 @@ require_relative 'marj_adapter'
6
6
  #
7
7
  # The {Marj} module provides the following methods:
8
8
  # - +query+ - Queries enqueued jobs
9
- # - +discard+ - Discards a job
9
+ # - +discard+ - Discards a job, by default by executing after_discard callbacks and delegating to delete
10
+ # - +delete+ - Deletes a job
10
11
  #
11
12
  # It is possible to call the above methods on the {Marj} module itself or on any class which includes it.
12
13
  #
13
14
  # Example usage:
14
15
  # Marj.query(:first) # Returns the first job
15
16
  # Marj.discard(job) # Discards the specified job
17
+ # Marj.delete(job) # Deletes the specified job
16
18
  #
17
19
  # class ApplicationJob < ActiveJob::Base
18
20
  # include Marj
@@ -25,33 +27,37 @@ require_relative 'marj_adapter'
25
27
  # job = ApplicationJob.query(:first) # Returns the first enqueued job
26
28
  # job = SomeJob.query(:first) # Returns the first enqueued job with job_class SomeJob
27
29
  # ApplicationJob.discard(job) # Discards the specified job
30
+ # ApplicationJob.delete(job) # Deletes the specified job
28
31
  # job.discard # Discards the job
32
+ # job.delete # Deletes the job
29
33
  #
30
34
  # See https://github.com/nicholasdower/marj
31
35
  module Marj
32
36
  # The Marj version.
33
- VERSION = '5.0.0'
37
+ VERSION = '6.1.0'
34
38
 
35
39
  Kernel.autoload(:Record, File.expand_path(File.join('marj', 'record.rb'), __dir__))
36
40
 
37
41
  # Provides the {query} and {discard} class methods.
38
42
  module ClassMethods
39
- # Queries enqueued jobs.
43
+ # Queries enqueued jobs. Similar to +ActiveRecord.where+ with a few additional features:
44
+ # - Symbol arguments are treated as +ActiveRecord+ scopes.
45
+ # - If only a job ID is specified, the corresponding job is returned.
46
+ # - If +:limit+ is specified, the maximum number of jobs is limited.
47
+ # - If +:order+ is specified, the jobs are ordered by the given attribute.
40
48
  #
41
- # Similar to +ActiveRecord.where+ with a few additional features.
49
+ # By default jobs are ordered by when they should be executed.
42
50
  #
43
51
  # Example usage:
44
- # query(:all) # Delegates to Marj::Record.all
45
- # query(:due) # Delegates to Marj::Record.due
46
- # query(:all, limit: 10) # Returns a maximum of 10 jobs
47
- # query(job_class: Foo) # Returns all jobs with job_class Foo
48
- #
49
- # query('123') # Returns the job with id '123' or nil if no such job exists
50
- # query(id: '123') # Same as above
51
- # query(job_id: '123') # Same as above
52
- #
53
- # query(queue: 'foo') # Returns all jobs in the 'foo' queue
54
- # query(job_queue: 'foo') # Same as above
52
+ # query # Returns all jobs
53
+ # query(:all) # Returns all jobs
54
+ # query(:due) # Returns jobs which are due to be executed
55
+ # query(:due, limit: 10) # Returns at most 10 jobs which are due to be executed
56
+ # query(job_class: Foo) # Returns all jobs with job_class Foo
57
+ # query(:due, job_class: Foo) # Returns jobs which are due to be executed with job_class Foo
58
+ # query(queue_name: 'foo') # Returns all jobs in the 'foo' queue
59
+ # query(job_id: '123') # Returns the job with job_id '123' or nil if no such job exists
60
+ # query('123') # Returns the job with job_id '123' or nil if no such job exists
55
61
  def query(*args, **kwargs)
56
62
  kwargs[:job_class] ||= self if self < ActiveJob::Base && name != 'ApplicationJob'
57
63
  queue_adapter.query(*args, **kwargs)
@@ -63,6 +69,13 @@ module Marj
63
69
  def discard(job)
64
70
  queue_adapter.discard(job)
65
71
  end
72
+
73
+ # Deletes the record associated with the specified job.
74
+ #
75
+ # @return [ActiveJob::Base] the deleted job
76
+ def delete(job)
77
+ queue_adapter.delete(job)
78
+ end
66
79
  end
67
80
 
68
81
  # (see ClassMethods#query)
@@ -75,13 +88,25 @@ module Marj
75
88
  queue_adapter.discard(job)
76
89
  end
77
90
 
78
- # Discards this job.
91
+ # (see ClassMethods#delete)
92
+ def self.delete(job)
93
+ queue_adapter.delete(job)
94
+ end
95
+
96
+ # Deletes this job.
79
97
  #
80
98
  # @return [ActiveJob::Base] this job
81
99
  def discard
82
100
  self.class.queue_adapter.discard(self)
83
101
  end
84
102
 
103
+ # Deletes the record associated with this job.
104
+ #
105
+ # @return [ActiveJob::Base] this job
106
+ def delete
107
+ self.class.queue_adapter.delete(self)
108
+ end
109
+
85
110
  def self.included(clazz)
86
111
  clazz.extend(ClassMethods)
87
112
  end
@@ -92,3 +117,15 @@ module Marj
92
117
  end
93
118
  private_class_method :queue_adapter
94
119
  end
120
+
121
+ # :nocov:
122
+ if defined?(Rails)
123
+ begin
124
+ require 'mission_control/jobs'
125
+ require_relative 'marj/mission_control'
126
+ MarjAdapter.include(Marj::MissionControl)
127
+ rescue LoadError
128
+ # ignore
129
+ end
130
+ end
131
+ # :nocov:
data/lib/marj_adapter.rb CHANGED
@@ -2,11 +2,13 @@
2
2
 
3
3
  # ActiveJob queue adapter for Marj.
4
4
  #
5
- # In addition to the standard +ActiveJob+ queue adapter API, this adapter provides a +query+ method which can be used to
6
- # query enqueued jobs and a +discard+ method which can be used to discard enqueued jobs.
5
+ # In addition to the standard +ActiveJob+ queue adapter API, this adapter provides:
6
+ # - A +query+ method which can be used to query enqueued jobs
7
+ # - A +discard+ method which can be used to discard enqueued jobs.
8
+ # - A +delete+ method which can be used to delete enqueued jobs.
7
9
  #
8
- # Although it is possible to access the adapter directly in order to query or discard, it is recommended to use the
9
- # {Marj} module.
10
+ # Although it is possible to access the adapter directly in order to query, discard or delete, it is recommended to use
11
+ # the {Marj} module.
10
12
  #
11
13
  # See https://github.com/nicholasdower/marj
12
14
  class MarjAdapter
@@ -15,9 +17,17 @@ class MarjAdapter
15
17
 
16
18
  # Creates a new adapter which will enqueue jobs using the given +ActiveRecord+ class.
17
19
  #
18
- # @param record_class [Class, String] the +ActiveRecord+ class (or its name) to use to store jobs
19
- def initialize(record_class = 'Marj::Record')
20
+ # @param record_class [Class, String] the +ActiveRecord+ class (or its name) to use, defaults to +Marj::Record+
21
+ # @param discard [Proc] the proc to use to discard jobs, defaults to delegating to {delete}
22
+ def initialize(record_class: 'Marj::Record', discard: proc { |job| delete(job) })
20
23
  @record_class = record_class
24
+ @discard_proc = discard
25
+ end
26
+
27
+ # Returns whether jobs enqueued during a transaction should actually be enqueued only after the
28
+ # transaction is committed.
29
+ def enqueue_after_transaction_commit?
30
+ false
21
31
  end
22
32
 
23
33
  # Enqueue a job for immediate execution.
@@ -77,25 +87,25 @@ class MarjAdapter
77
87
  end
78
88
 
79
89
  # Queries enqueued jobs. Similar to +ActiveRecord.where+ with a few additional features:
80
- # - Leading symbol arguments are treated as +ActiveRecord+ scopes.
90
+ # - Symbol arguments are treated as +ActiveRecord+ scopes.
81
91
  # - If only a job ID is specified, the corresponding job is returned.
82
92
  # - If +:limit+ is specified, the maximum number of jobs is limited.
93
+ # - If +:order+ is specified, the jobs are ordered by the given attribute.
83
94
  #
84
- # Example usage:
85
- # query(:all) # Delegates to Marj::Record.all
86
- # query(:due) # Delegates to Marj::Record.due
87
- # query(:all, limit: 10) # Returns a maximum of 10 jobs
88
- # query(job_class: Foo) # Returns all jobs with job_class Foo
89
- #
90
- # query('123') # Returns the job with id '123' or nil if no such job exists
91
- # query(id: '123') # Same as above
92
- # query(job_id: '123') # Same as above
95
+ # By default jobs are ordered by when they should be executed.
93
96
  #
94
- # query(queue: 'foo') # Returns all jobs in the 'foo' queue
95
- # query(job_queue: 'foo') # Same as above
97
+ # Example usage:
98
+ # query # Returns all jobs
99
+ # query(:all) # Returns all jobs
100
+ # query(:due) # Returns jobs which are due to be executed
101
+ # query(:due, limit: 10) # Returns at most 10 jobs which are due to be executed
102
+ # query(job_class: Foo) # Returns all jobs with job_class Foo
103
+ # query(:due, job_class: Foo) # Returns jobs which are due to be executed with job_class Foo
104
+ # query(queue_name: 'foo') # Returns all jobs in the 'foo' queue
105
+ # query(job_id: '123') # Returns the job with job_id '123' or nil if no such job exists
106
+ # query('123') # Returns the job with job_id '123' or nil if no such job exists
96
107
  def query(*args, **kwargs)
97
108
  args, kwargs = args.dup, kwargs.dup.symbolize_keys
98
- kwargs = kwargs.merge(queue_name: kwargs.delete(:queue)) if kwargs.key?(:queue)
99
109
  kwargs = kwargs.merge(job_id: kwargs.delete(:id)) if kwargs.key?(:id)
100
110
  kwargs[:job_id] = args.shift if args.size == 1 && args.first.is_a?(String) && args.first.match(JOB_ID_REGEX)
101
111
 
@@ -103,18 +113,16 @@ class MarjAdapter
103
113
  return record_class.find_by(job_id: kwargs[:job_id])&.to_job
104
114
  end
105
115
 
106
- symbol_args = []
107
- symbol_args << args.shift while args.first.is_a?(Symbol)
108
- order_by = kwargs.delete(:order)
109
- order_by = :queue_name if [:queue, 'queue'].include?(order_by)
116
+ symbol_args, args = args.partition { _1.is_a?(Symbol) }
117
+ symbol_args.delete(:all)
110
118
  limit = kwargs.delete(:limit)
111
- symbol_args.shift if symbol_args.first == :all
112
119
  relation = record_class.all
113
- relation = relation.order(order_by) if order_by
114
- relation = relation.by_due_date unless relation.order_values.any?
120
+ relation = relation.order(kwargs.delete(:order)) if kwargs.key?(:order)
115
121
  relation = relation.where(*args, **kwargs) if args.any? || kwargs.any?
116
122
  relation = relation.limit(limit) if limit
117
123
  relation = relation.send(symbol_args.shift) while symbol_args.any?
124
+ relation = relation.by_due_date if relation.is_a?(ActiveRecord::Relation) && relation.order_values.empty?
125
+
118
126
  if relation.is_a?(Enumerable)
119
127
  relation.map(&:to_job)
120
128
  elsif relation.is_a?(record_class)
@@ -126,12 +134,46 @@ class MarjAdapter
126
134
 
127
135
  # Discards the specified job.
128
136
  #
137
+ # @param job [ActiveJob::Base] the job being discarded
138
+ # @param run_callbacks [Boolean] whether to run the +after_discard+ callbacks
129
139
  # @return [ActiveJob::Base] the discarded job
130
- def discard(job)
140
+ def discard(job, run_callbacks: true)
141
+ job.tap do
142
+ @discard_proc.call(job)
143
+ run_after_discard_callbacks(job) if run_callbacks
144
+ end
145
+ end
146
+
147
+ # Deletes the record associated with the specified job.
148
+ #
149
+ # @return [ActiveJob::Base] the deleted job
150
+ def delete(job)
151
+ job.tap { destroy_record(job) }
152
+ end
153
+
154
+ private
155
+
156
+ # Returns the +ActiveRecord+ class to use to store jobs.
157
+ #
158
+ # @return [Class] the +ActiveRecord+ class
159
+ def record_class
160
+ @record_class = @record_class.is_a?(String) ? @record_class.constantize : @record_class
161
+ end
162
+
163
+ # Destroys the record associated with the given job if it exists.
164
+ #
165
+ # @return [ActiveRecord::Base, NilClass] the destroyed record or +nil+ if no such record exists
166
+ def destroy_record(job)
131
167
  record = job.singleton_class.instance_variable_get(:@record)
132
168
  record ||= Marj::Record.find_by(job_id: job.job_id)&.tap { _1.send(:register_callbacks, job) }
133
169
  record&.destroy
170
+ end
134
171
 
172
+ # Invokes the specified job's +after_discard+ callbacks.
173
+ #
174
+ # @param job [ActiveJob::Base] the job being discarded
175
+ # @return [NilClass] the given job
176
+ def run_after_discard_callbacks(job)
135
177
  # Copied from ActiveJob::Exceptions#run_after_discard_procs
136
178
  exceptions = []
137
179
  job.after_discard_procs.each do |blk|
@@ -141,12 +183,6 @@ class MarjAdapter
141
183
  end
142
184
  raise exceptions.last if exceptions.any?
143
185
 
144
- job
145
- end
146
-
147
- private
148
-
149
- def record_class
150
- @record_class = @record_class.is_a?(String) ? @record_class.constantize : @record_class
186
+ nil
151
187
  end
152
188
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: marj
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0
4
+ version: 6.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Dower
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-01-31 00:00:00.000000000 Z
11
+ date: 2024-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -48,6 +48,7 @@ files:
48
48
  - LICENSE.txt
49
49
  - README.md
50
50
  - lib/marj.rb
51
+ - lib/marj/mission_control.rb
51
52
  - lib/marj/record.rb
52
53
  - lib/marj_adapter.rb
53
54
  homepage: https://github.com/nicholasdower/marj
@@ -55,8 +56,8 @@ licenses:
55
56
  - MIT
56
57
  metadata:
57
58
  bug_tracker_uri: https://github.com/nicholasdower/marj/issues
58
- changelog_uri: https://github.com/nicholasdower/marj/releases/tag/v5.0.0
59
- documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/v5.0.0
59
+ changelog_uri: https://github.com/nicholasdower/marj/releases/tag/v6.1.0
60
+ documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/v6.1.0
60
61
  homepage_uri: https://github.com/nicholasdower/marj
61
62
  rubygems_mfa_required: 'true'
62
63
  source_code_uri: https://github.com/nicholasdower/marj