marj 5.0.0 → 6.0.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: 6d0039931eb3f0bd43ce090b0b581d6544b26fda022729f6371f4c8ac393076e
4
+ data.tar.gz: 6abb66afe0bc56064be4ecee3f3e59e78adcf10afb83654529387b785f817335
5
5
  SHA512:
6
- metadata.gz: 1e49dcbc43034f454fb57bf1dca72972eba67719cd8c9aa1aa43b10f91287d2bab41bb97025dbca688de9e6630f0c0d494e6607100382165f56a0f93a38fc5e4
7
- data.tar.gz: d075bd8f29864ea83be5ce0099f925f9bb527f9373940427c98cfce3545ee25a565a1fb6293023938bbd39b320ab55015b924281a31bdabb43085398551342cc
6
+ metadata.gz: ec25e50e772072eb6d13ebcb04a67e8ee4602782b62edab07e9521817993f6d92bac376bff90a6aeca1c724fa61dc4ba04ac1fe8ce9659139d2dcd98c397bce4
7
+ data.tar.gz: f78b5ba741202818a5374c95fddcb6258b091d4feba4b2de1d1f9de8d1422b99348bf5e354619d215b32b9b7f8d4055ef95d271055cf489e4049b06b40d3463c
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.0.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 # Performs 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
@@ -101,29 +120,26 @@ end
101
120
  ```ruby
102
121
  require 'marj'
103
122
 
123
+ # Choose one of the following:
104
124
  Rails.configuration.active_job.queue_adapter = :marj # Globally, with Rails
105
125
  ActiveJob::Base.queue_adapter = :marj # Globally, without Rails
106
126
  SomeJob.queue_adapter = :marj # Single job
107
127
  ```
108
128
 
109
- ### 4. Include the Marj module (optional)
129
+ ### 4. Optionally, include the Marj module
110
130
 
111
- By default, jobs can be queried and discarded via the `MarjAdapter` or the
112
- `Marj` module:
131
+ Without any additional setup, jobs can be queried and discarded via the `Marj`
132
+ module:
113
133
 
114
134
  ```ruby
115
135
  Marj.query(:all)
116
- ActiveJob::Base.queue_adapter.query(:all)
117
136
  Marj.discard(job)
118
- ActiveJob::Base.queue_adapter.discard(job)
119
137
  ```
120
138
 
121
139
  But it is also convenient to query or discard via job classes:
122
140
 
123
141
  ```ruby
124
- ApplicationJob.query(:all)
125
142
  SomeJob.query(:all)
126
- ApplicationJob.discard(job)
127
143
  SomeJob.discard(job)
128
144
  job.discard
129
145
  ```
@@ -131,11 +147,9 @@ job.discard
131
147
  In order to enable this functionality, you must include the `Marj` module:
132
148
 
133
149
  ```ruby
134
- class ApplicationJob < ActiveJob::Base
150
+ class SomeJob < ActiveJob::Base
135
151
  include Marj
136
- end
137
152
 
138
- class SomeJob < ApplicationJob
139
153
  def perform; end
140
154
  end
141
155
  ```
@@ -143,27 +157,29 @@ end
143
157
  ## Example Usage
144
158
 
145
159
  ```ruby
146
- # Enqueue and manually run a job
160
+ # Enqueue a job
147
161
  job = SomeJob.perform_later('foo')
148
- job.perform_now
149
162
 
150
- # Retrieve and execute a job
151
- Marj.query(:due, :first).perform_now
163
+ # Query jobs
164
+ SomeJob.query(:all)
165
+ SomeJob.query(:due)
166
+ SomeJob.query(:due, queue: :foo)
167
+ SomeJob.query('8720417d-8fff-4fcf-bc16-22aaef8543d2')
152
168
 
153
- # Run all due jobs (single DB query)
154
- Marj.query(:due).map(&:perform_now)
169
+ # Execute a job
170
+ job.perform_now
155
171
 
156
- # Run all due jobs (multiple DB queries)
157
- loop do
158
- break unless Marj.query(:due, :first)&.tap(&:perform_now)
159
- end
172
+ # Execute jobs which are due (single query)
173
+ SomeJob.query(:due).map(&:perform_now)
160
174
 
161
- # Run all jobs in a specific queue which are due to be executed
162
- Marj.query(:due, queue: :foo).map(&:perform_now)
175
+ # Execute jobs which are due (multiple queries)
176
+ loop {
177
+ SomeJob.query(:due, :first)&.tap(&:perform_now) || break
178
+ end
163
179
 
164
- # Run jobs as they become due
180
+ # Execute jobs as they become due
165
181
  loop do
166
- Marj.query(:due).each(&:perform_now) rescue logger.error($!)
182
+ SomeJob.query(:due).each(&:perform_now) rescue logger.error($!)
167
183
  ensure
168
184
  sleep 5.seconds
169
185
  end
@@ -175,17 +191,54 @@ By default, jobs enqeued during tests will be written to the database. Enqueued
175
191
  jobs can be executed via:
176
192
 
177
193
  ```ruby
178
- Marj.due.perform_all
194
+ SomeJob.query(:due).map(&:perform_now)
179
195
  ```
180
196
 
181
- Alternatively, to use [ActiveJob::QueueAdapters::TestAdapter](https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/TestAdapter.html):
197
+ Alternatively, to use ActiveJob's [TestAdapter](https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/TestAdapter.html):
182
198
  ```ruby
183
199
  ActiveJob::Base.queue_adapter = :test
184
200
  ```
185
201
 
186
202
  ## Extension Examples
187
203
 
188
- ### Timeouts
204
+ Most missing features can easily be added to your application with a few lines
205
+ of code.
206
+
207
+ ### Concurrency Protection
208
+
209
+ To prevent two threads from executing the same job simultaneously, consider
210
+ moving jobs to a different queue before executing them.
211
+
212
+ ```ruby
213
+ class ApplicationJob < ActiveJob::Base
214
+ around_perform do |job, block|
215
+ if job.queue_name.start_with?('claimed')
216
+ raise "Job #{job.job_id} already claimed"
217
+ end
218
+
219
+ updated = Marj::Record
220
+ .where(job_id: job.job_id, queue_name: job.queue_name)
221
+ .update_all(
222
+ queue_name: "claimed-#{job.queue_name}",
223
+ scheduled_at: Time.now.utc
224
+ )
225
+ unless updated == 1
226
+ raise "Failed to claim job #{job.job_id}. #{updated} records updated"
227
+ end
228
+
229
+ begin
230
+ block.call
231
+ rescue StandardError
232
+ Marj::Record
233
+ .where(job_id: job.job_id, queue_name: "claimed-#{job.queue_name}")
234
+ .update_all(queue_name: job.queue_name, scheduled_at: job.scheduled_at)
235
+ raise
236
+ end
237
+ end
238
+ end
239
+ ```
240
+
241
+ ### Job Timeouts
189
242
 
190
243
  ```ruby
191
244
  class ApplicationJob < ActiveJob::Base
@@ -205,7 +258,7 @@ class ApplicationJob < ActiveJob::Base
205
258
  end
206
259
  ```
207
260
 
208
- ### Last Error
261
+ ### Error Storage
209
262
 
210
263
  ```ruby
211
264
  class AddLastErrorToJobs < ActiveRecord::Migration[7.1]
@@ -246,51 +299,77 @@ class ApplicationJob < ActiveJob::Base
246
299
  end
247
300
  ```
248
301
 
249
- ### Multiple Tables/Databases
302
+ ### Discarded Job Retention
250
303
 
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.
304
+ By default, discarded jobs are deleted from the database. If desired, this
305
+ behavior can be overridden, for instance to move jobs to a differnt queue:
253
306
 
254
307
  ```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
308
+ ActiveJob::Base.queue_adapter = MarjAdapter.new(
309
+ discard: proc { _1.enqueue(queue: 'discarded') }
310
+ )
311
+
312
+ # To retrieve a discarded job, query the discarded queue:
313
+ job = Marj.query(:first, queue_name: 'discarded')
314
+
315
+ # To delete, use one of the following:
316
+ Marj.delete(job)
317
+ job.delete
318
+ ```
269
319
 
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]
320
+ ### Observability
321
+
322
+ For instance with Prometheus metrics:
323
+
324
+ ```ruby
325
+ class ApplicationJob < ActiveJob::Base
326
+ counter = Prometheus::Client::Counter.new(
327
+ :job_events_total, docstring: '...', labels: [:job, :event]
328
+ )
329
+
330
+ around_enqueue do |job, block|
331
+ counter.increment(labels: { job: job.class.name, event: 'before_enqueue' })
332
+ block.call
333
+ counter.increment(labels: { job: job.class.name, event: 'after_enqueue' })
334
+ rescue Exception
335
+ counter.increment(labels: { job: job.class.name, event: 'enqueue_error' })
336
+ raise
273
337
  end
274
338
 
275
- def self.down
276
- drop_table :my_jobs
339
+ around_perform do |job, block|
340
+ counter.increment(labels: { job: job.class.name, event: 'before_perform' })
341
+ block.call
342
+ counter.increment(labels: { job: job.class.name, event: 'after_perform' })
343
+ rescue Exception => e # rubocop:disable Lint/RescueException
344
+ counter.increment(labels: { job: job.class.name, event: 'perform_error' })
345
+ raise
346
+ end
347
+
348
+ after_discard do |job|
349
+ counter.increment(labels: { job: job.class.name, event: 'after_discard' })
277
350
  end
278
351
  end
352
+ ```
353
+
354
+ ### Multi-DB Support
355
+
356
+ It is possible to create a custom record class in order to, for instance,
357
+ use a different class or table name, or write jobs to multiple
358
+ databases/tables within a single application.
359
+
360
+ Assuming you have a jobs tabled named `my_jobs`:
279
361
 
362
+ ```ruby
280
363
  class MyRecord < Marj::Record
281
364
  self.table_name = 'my_jobs'
282
365
  end
283
366
 
284
- CreateMyJobs.migrate(:up)
285
-
286
367
  class MyJob < ActiveJob::Base
287
- self.queue_adapter = MarjAdapter.new('MyRecord')
288
-
289
368
  include Marj
290
369
 
291
- def perform(msg)
292
- puts msg
293
- end
370
+ self.queue_adapter = MarjAdapter.new('MyRecord')
371
+
372
+ def perform; end
294
373
  end
295
374
 
296
375
  MyJob.perform_later('oh, hi')
@@ -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.0.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,11 @@ 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
21
25
  end
22
26
 
23
27
  # Enqueue a job for immediate execution.
@@ -77,25 +81,25 @@ class MarjAdapter
77
81
  end
78
82
 
79
83
  # Queries enqueued jobs. Similar to +ActiveRecord.where+ with a few additional features:
80
- # - Leading symbol arguments are treated as +ActiveRecord+ scopes.
84
+ # - Symbol arguments are treated as +ActiveRecord+ scopes.
81
85
  # - If only a job ID is specified, the corresponding job is returned.
82
86
  # - If +:limit+ is specified, the maximum number of jobs is limited.
87
+ # - If +:order+ is specified, the jobs are ordered by the given attribute.
83
88
  #
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
89
+ # By default jobs are ordered by when they should be executed.
93
90
  #
94
- # query(queue: 'foo') # Returns all jobs in the 'foo' queue
95
- # query(job_queue: 'foo') # Same as above
91
+ # Example usage:
92
+ # query # Returns all jobs
93
+ # query(:all) # Returns all jobs
94
+ # query(:due) # Returns jobs which are due to be executed
95
+ # query(:due, limit: 10) # Returns at most 10 jobs which are due to be executed
96
+ # query(job_class: Foo) # Returns all jobs with job_class Foo
97
+ # query(:due, job_class: Foo) # Returns jobs which are due to be executed with job_class Foo
98
+ # query(queue_name: 'foo') # Returns all jobs in the 'foo' queue
99
+ # query(job_id: '123') # Returns the job with job_id '123' or nil if no such job exists
100
+ # query('123') # Returns the job with job_id '123' or nil if no such job exists
96
101
  def query(*args, **kwargs)
97
102
  args, kwargs = args.dup, kwargs.dup.symbolize_keys
98
- kwargs = kwargs.merge(queue_name: kwargs.delete(:queue)) if kwargs.key?(:queue)
99
103
  kwargs = kwargs.merge(job_id: kwargs.delete(:id)) if kwargs.key?(:id)
100
104
  kwargs[:job_id] = args.shift if args.size == 1 && args.first.is_a?(String) && args.first.match(JOB_ID_REGEX)
101
105
 
@@ -103,18 +107,16 @@ class MarjAdapter
103
107
  return record_class.find_by(job_id: kwargs[:job_id])&.to_job
104
108
  end
105
109
 
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)
110
+ symbol_args, args = args.partition { _1.is_a?(Symbol) }
111
+ symbol_args.delete(:all)
110
112
  limit = kwargs.delete(:limit)
111
- symbol_args.shift if symbol_args.first == :all
112
113
  relation = record_class.all
113
- relation = relation.order(order_by) if order_by
114
- relation = relation.by_due_date unless relation.order_values.any?
114
+ relation = relation.order(kwargs.delete(:order)) if kwargs.key?(:order)
115
115
  relation = relation.where(*args, **kwargs) if args.any? || kwargs.any?
116
116
  relation = relation.limit(limit) if limit
117
117
  relation = relation.send(symbol_args.shift) while symbol_args.any?
118
+ relation = relation.by_due_date if relation.is_a?(ActiveRecord::Relation) && relation.order_values.empty?
119
+
118
120
  if relation.is_a?(Enumerable)
119
121
  relation.map(&:to_job)
120
122
  elsif relation.is_a?(record_class)
@@ -126,12 +128,46 @@ class MarjAdapter
126
128
 
127
129
  # Discards the specified job.
128
130
  #
131
+ # @param job [ActiveJob::Base] the job being discarded
132
+ # @param run_callbacks [Boolean] whether to run the +after_discard+ callbacks
129
133
  # @return [ActiveJob::Base] the discarded job
130
- def discard(job)
134
+ def discard(job, run_callbacks: true)
135
+ job.tap do
136
+ @discard_proc.call(job)
137
+ run_after_discard_callbacks(job) if run_callbacks
138
+ end
139
+ end
140
+
141
+ # Deletes the record associated with the specified job.
142
+ #
143
+ # @return [ActiveJob::Base] the deleted job
144
+ def delete(job)
145
+ job.tap { destroy_record(job) }
146
+ end
147
+
148
+ private
149
+
150
+ # Returns the +ActiveRecord+ class to use to store jobs.
151
+ #
152
+ # @return [Class] the +ActiveRecord+ class
153
+ def record_class
154
+ @record_class = @record_class.is_a?(String) ? @record_class.constantize : @record_class
155
+ end
156
+
157
+ # Destroys the record associated with the given job if it exists.
158
+ #
159
+ # @return [ActiveRecord::Base, NilClass] the destroyed record or +nil+ if no such record exists
160
+ def destroy_record(job)
131
161
  record = job.singleton_class.instance_variable_get(:@record)
132
162
  record ||= Marj::Record.find_by(job_id: job.job_id)&.tap { _1.send(:register_callbacks, job) }
133
163
  record&.destroy
164
+ end
134
165
 
166
+ # Invokes the specified job's +after_discard+ callbacks.
167
+ #
168
+ # @param job [ActiveJob::Base] the job being discarded
169
+ # @return [NilClass] the given job
170
+ def run_after_discard_callbacks(job)
135
171
  # Copied from ActiveJob::Exceptions#run_after_discard_procs
136
172
  exceptions = []
137
173
  job.after_discard_procs.each do |blk|
@@ -141,12 +177,6 @@ class MarjAdapter
141
177
  end
142
178
  raise exceptions.last if exceptions.any?
143
179
 
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
180
+ nil
151
181
  end
152
182
  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.0.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-02-05 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.0.0
60
+ documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/v6.0.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