marj 2.1.0 → 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '078c3d48b27784e09425ba8eda2207038d6ce5c7a711953e8567c76aac689435'
4
- data.tar.gz: c129b4848de1e2fe55de0de3a4893586a052cd814df798879dc196a81c1a94d5
3
+ metadata.gz: 4eb6129fa4948ed6e108469f44f380cda908620c6d08392cfa1e8c3bc84a9875
4
+ data.tar.gz: 28faed9a10fa3a8c885b521d6c3465196f814e97af07018d5eea88e580f322a9
5
5
  SHA512:
6
- metadata.gz: 4cef48fb7c862123f4a5ad7ed5bf7a9d95eb3d7036831db79ad3dc9cf94f074a112ce58f0189484f991a52185cdf8e8ceed48dd9237bf4a04d5e3bbe30b2d274
7
- data.tar.gz: 7631b1a0fef1ece1edcf15541dc85d25b2d5d3ba1cb8ca716133975126e5ce9bb4f28d627a4c2a5d751029ce8f67ba4db47c6fa9d197467fade4c09d20a04716
6
+ metadata.gz: 5f984f366a9cefb3187cbac7129144c8241d46d00357d719ec442dfce62d5c833c95ec30363a5af867e111499cc91cd2c60768da55d04c9c7fc401cce8ac3f83
7
+ data.tar.gz: e86029649f11ca05f96efcae59c1c60212b29335d9b154c3288a15aa81cd432b46f960166680b5bb0a534346852ccc73970cbaf8a298eb52893c37e260854f61
data/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Marj - Minimal ActiveRecord Jobs
2
2
 
3
- The simplest database-backed ActiveJob queueing backend.
3
+ A minimal database-backed ActiveJob queueing backend.
4
4
 
5
5
  ## Quick Links
6
6
 
7
- API docs: https://www.rubydoc.info/github/nicholasdower/marj <br>
7
+ API docs: https://gemdocs.org/gems/marj/latest <br>
8
8
  RubyGems: https://rubygems.org/gems/marj <br>
9
9
  Changelog: https://github.com/nicholasdower/marj/releases <br>
10
10
  Issues: https://github.com/nicholasdower/marj/issues <br>
@@ -16,6 +16,8 @@ Development: https://github.com/nicholasdower/marj/blob/master/CONTRIBUTING.md
16
16
  - Successfully executed jobs are deleted from the database.
17
17
  - Failed jobs which should be retried are updated in the database.
18
18
  - Failed jobs which should not be retried are deleted from the database.
19
+ - An interface is provided to retrieve, execute, discard and re-enqueue jobs.
20
+ - An `ActiveRecord` class is provided to query the database directly.
19
21
 
20
22
  ## Features Not Provided
21
23
 
@@ -24,35 +26,17 @@ Development: https://github.com/nicholasdower/marj/blob/master/CONTRIBUTING.md
24
26
  - Concurrency Controls
25
27
  - Observability
26
28
  - A User Interace
27
- - Anything else you might dream up.
28
-
29
- ## Interface
30
-
31
- - `Marj` - An ActiveRecord model class
32
- - `Marj.ready` - Used to retrieve jobs ready to be executed
33
- - `Marj#execute` - Used to execute jobs retrieved from the database
34
- - `MarjConfig.table_name=` - Used to override the default table name
35
29
 
36
30
  ## Setup
37
31
 
38
32
  ### 1. Install
39
33
 
40
34
  ```shell
41
- bundle add marj
35
+ bundle add activejob activerecord marj
42
36
 
43
37
  # or
44
38
 
45
- gem install marj
46
- ```
47
-
48
- ### 2. Configure
49
-
50
- By default, the database table is named "jobs". To use a different table name:
51
-
52
- ```ruby
53
- require 'marj'
54
-
55
- MarjConfig.table_name = 'some_name'
39
+ gem install activejob activerecord marj
56
40
  ```
57
41
 
58
42
  ### 3. Create the database table
@@ -84,23 +68,17 @@ class CreateJobs < ActiveRecord::Migration[7.1]
84
68
  end
85
69
  ```
86
70
 
71
+ Note that by default, Marj uses a table named `jobs`. To override the default
72
+ table name, set `Marj.table_name` before loading `ActiveRecord`.
73
+
87
74
  ### 4. Configure the queue adapter
88
75
 
89
76
  ```ruby
90
77
  require 'marj'
91
78
 
92
- # With rails:
93
- class MyApplication < Rails::Application
94
- config.active_job.queue_adapter = :marj
95
- end
96
-
97
- # Without Rails:
98
- ActiveJob::Base.queue_adapter = :marj
99
-
100
- # Or for specific jobs (with or without Rails):
101
- class SomeJob < ActiveJob::Base
102
- self.queue_adapter = :marj
103
- end
79
+ Rails.configuration.active_job.queue_adapter = :marj # Globally, with Rails
80
+ ActiveJob::Base.queue_adapter = :marj # Globally, without Rails
81
+ SomeJob.queue_adapter = :marj # Single job
104
82
  ```
105
83
 
106
84
  ## Example Usage
@@ -110,35 +88,139 @@ end
110
88
  job = SomeJob.perform_later('foo')
111
89
  job.perform_now
112
90
 
113
- # Enqueue, retrieve and manually run a job:
114
- SomeJob.perform_later('foo')
115
- Marj.first.execute
91
+ # Retrieve and execute a job
92
+ Marj.due.next.perform_now
116
93
 
117
- # Run all ready jobs:
118
- Marj.ready.each(&:execute)
94
+ # Run all due jobs (single DB query)
95
+ Marj.due.perform_all
119
96
 
120
- # Run all ready jobs, querying each time:
121
- loop { Marj.ready.first&.tap(&:execute) || break }
97
+ # Run all due jobs (multiple DB queries)
98
+ Marj.due.perform_all(batch_size: 1)
122
99
 
123
- # Run all ready jobs in a specific queue:
124
- loop { Marj.where(queue_name: 'foo').ready.first&.tap(&:execute) || break }
100
+ # Run all due jobs in a specific queue:
101
+ Marj.queue('foo').due.perform_all
125
102
 
126
- # Run jobs as they become ready:
103
+ # Run jobs as they become due:
127
104
  loop do
128
- loop { Marj.ready.first&.tap(&:execute) || break }
129
- rescue Exception => e
130
- logger.error(e)
105
+ Marj.due.perform_all rescue logger.error($!)
131
106
  ensure
132
107
  sleep 5.seconds
133
108
  end
134
109
  ```
135
110
 
111
+ # Jobs Interface
112
+
113
+ The `Marj` module provides methods for interacting with enqueued jobs. These
114
+ methods accept, return and yield +ActiveJob+ objects rather than +ActiveRecord+
115
+ objects. Returned jobs are orderd by due date. To query the database directly,
116
+ use `Marj::Record`.
117
+
118
+ Example usage:
119
+
120
+ ```ruby
121
+ Marj.all # Returns all enqueued jobs.
122
+ Marj.queue # Returns jobs in the specified queue(s).
123
+ Marj.due # Returns jobs which are due to be executed.
124
+ Marj.next # Returns the next job(s) to be executed.
125
+ Marj.count # Returns the number of enqueued jobs.
126
+ Marj.where # Returns jobs matching the specified criteria.
127
+ Marj.perform_all # Executes all jobs.
128
+ Marj.discard_all # Discards all jobs.
129
+ Marj.discard # Discards the specified job.
130
+ ```
131
+
132
+ Query methods can also be chained:
133
+
134
+ ```ruby
135
+ Marj.due.where(job_class: SomeJob).next # Returns the next SomeJob that is due
136
+ ```
137
+
138
+ # Custom Jobs Interface
139
+
140
+ The `Marj::JobsInterface` can be added to any class or module. For example, to
141
+ add it to all jobs classes:
142
+
143
+ ```ruby
144
+ class ApplicationJob < ActiveJob::Base
145
+ extend Marj::JobsInterface
146
+
147
+ def self.all
148
+ Marj::Relation.new(
149
+ self == ApplicationJob ?
150
+ Marj::Record.ordered : Marj::Record.where(job_class: self)
151
+ )
152
+ end
153
+ end
154
+
155
+ class SomeJob < ApplicationJob; end
156
+
157
+ ApplicationJob.due # Returns all jobs which are due to be executed.
158
+ SomeJob.due # Returns SomeJobs which are due to be executed.
159
+ ```
160
+
161
+ ## Customization
162
+
163
+ It is possible to create a custom record class and jobs interface. This enables,
164
+ for instance, writing jobs to multiple databases/tables within a single
165
+ application.
166
+
167
+ ```ruby
168
+ class CreateMyJobs < ActiveRecord::Migration[7.1]
169
+ def self.up
170
+ create_table :my_jobs, id: :string, primary_key: :job_id do |table|
171
+ table.string :job_class, null: false
172
+ table.text :arguments, null: false
173
+ table.string :queue_name, null: false
174
+ table.integer :priority
175
+ table.integer :executions, null: false
176
+ table.text :exception_executions, null: false
177
+ table.datetime :enqueued_at, null: false
178
+ table.datetime :scheduled_at
179
+ table.string :locale, null: false
180
+ table.string :timezone, null: false
181
+ end
182
+
183
+ add_index :my_jobs, %i[enqueued_at]
184
+ add_index :my_jobs, %i[scheduled_at]
185
+ add_index :my_jobs, %i[priority scheduled_at enqueued_at]
186
+ end
187
+
188
+ def self.down
189
+ drop_table :my_jobs
190
+ end
191
+ end
192
+
193
+ class MyRecord < Marj::Record
194
+ self.table_name = 'my_jobs'
195
+ end
196
+
197
+ CreateMyJobs.migrate(:up)
198
+
199
+ class MyJob < ActiveJob::Base
200
+ self.queue_adapter = MarjAdapter.new('MyRecord')
201
+
202
+ extend Marj::JobsInterface
203
+
204
+ def self.all
205
+ Marj::Relation.new(MyRecord.all)
206
+ end
207
+
208
+ def perform(msg)
209
+ puts msg
210
+ end
211
+ end
212
+
213
+ MyJob.perform_later('oh, hi')
214
+ MyJob.due.next.perform_now
215
+ ```
216
+
136
217
  ## Testing
137
218
 
138
- By default, jobs enqeued during tests will be written to the database. Enqueued jobs can be executed via:
219
+ By default, jobs enqeued during tests will be written to the database. Enqueued
220
+ jobs can be executed via:
139
221
 
140
222
  ```ruby
141
- Marj.ready.each(&:execute)
223
+ Marj.due.perform_all
142
224
  ```
143
225
 
144
226
  Alternatively, to use [ActiveJob::QueueAdapters::TestAdapter](https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/TestAdapter.html):
@@ -158,7 +240,9 @@ class ApplicationJob < ActiveJob::Base
158
240
 
159
241
  around_perform do |job, block|
160
242
  if (timeout = job.class.instance_variable_get(:@timeout))
161
- ::Timeout.timeout(timeout, StandardError, 'execution expired') { block.call }
243
+ ::Timeout.timeout(timeout, StandardError, 'execution expired') do
244
+ block.call
245
+ end
162
246
  else
163
247
  block.call
164
248
  end
@@ -185,7 +269,9 @@ class ApplicationJob < ActiveJob::Base
185
269
  def last_error=(error)
186
270
  if error.is_a?(Exception)
187
271
  backtrace = error.backtrace&.map { |line| "\t#{line}" }&.join("\n")
188
- error = backtrace ? "#{error.class}: #{error.message}\n#{backtrace}" : "#{error.class}: #{error.message}"
272
+ error = backtrace ?
273
+ "#{error.class}: #{error.message}\n#{backtrace}" :
274
+ "#{error.class}: #{error.message}"
189
275
  end
190
276
 
191
277
  @last_error = error&.truncate(10_000, omission: '… (truncated)')
@@ -217,10 +303,8 @@ For more information on ActiveJob, see:
217
303
 
218
304
  ```ruby
219
305
  # With Rails
220
- class MyApplication < Rails::Application
221
- config.active_job.queue_adapter = :foo # Instantiates FooAdapter
222
- config.active_job.queue_adapter = FooAdapter.new # Uses FooAdapter directly
223
- end
306
+ Rails.configuration.active_job.queue_adapter = :foo # Instantiates FooAdapter
307
+ Rails.configuration.active_job.queue_adapter = FooAdapter.new
224
308
 
225
309
  # Without Rails
226
310
  ActiveJob::Base.queue_adapter = :foo # Instantiates FooAdapter
@@ -233,40 +317,48 @@ SomeJob.queue_adapter = FooAdapter.new # Uses FooAdapter directly
233
317
 
234
318
  ### Configuration
235
319
 
236
- - `config.active_job.default_queue_name`
237
- - `config.active_job.queue_name_prefix`
238
- - `config.active_job.queue_name_delimiter`
239
- - `config.active_job.retry_jitter`
240
- - `SomeJob.queue_name_prefix`
241
- - `SomeJob.queue_name_delimiter`
242
- - `SomeJob.retry_jitter`
243
- - `SomeJob.queue_name`
244
- - `SomeJob.queue_as`
320
+ ```ruby
321
+ config.active_job.default_queue_name
322
+ config.active_job.queue_name_prefix
323
+ config.active_job.queue_name_delimiter
324
+ config.active_job.retry_jitter
325
+ SomeJob.queue_name
326
+ SomeJob.queue_as
327
+ SomeJob.queue_name_prefix
328
+ SomeJob.queue_name_delimiter
329
+ SomeJob.retry_jitter
330
+ ```
245
331
 
246
332
  ### Options
247
333
 
248
- - `:wait` - Enqueues the job with the specified delay
249
- - `:wait_until` - Enqueues the job at the time specified
250
- - `:queue` - Enqueues the job on the specified queue
251
- - `:priority` - Enqueues the job with the specified priority
334
+ ```ruby
335
+ :wait # Enqueues the job with the specified delay
336
+ :wait_until # Enqueues the job at the time specified
337
+ :queue # Enqueues the job on the specified queue
338
+ :priority # Enqueues the job with the specified priority
339
+ ```
252
340
 
253
341
  ### Callbacks
254
342
 
255
- - `SomeJob.before_enqueue`
256
- - `SomeJob.after_enqueue`
257
- - `SomeJob.around_enqueue`
258
- - `SomeJob.before_perform`
259
- - `SomeJob.after_perform`
260
- - `SomeJob.around_perform`
261
- - `ActiveJob::Callbacks.singleton_class.set_callback(:execute, :before, &block)`
262
- - `ActiveJob::Callbacks.singleton_class.set_callback(:execute, :after, &block)`
263
- - `ActiveJob::Callbacks.singleton_class.set_callback(:execute, :around, &block)`
343
+ ```ruby
344
+ SomeJob.before_enqueue
345
+ SomeJob.after_enqueue
346
+ SomeJob.around_enqueue
347
+ SomeJob.before_perform
348
+ SomeJob.after_perform
349
+ SomeJob.around_perform
350
+ ActiveJob::Callbacks.singleton_class.set_callback(:execute, :before, &block)
351
+ ActiveJob::Callbacks.singleton_class.set_callback(:execute, :after, &block)
352
+ ActiveJob::Callbacks.singleton_class.set_callback(:execute, :around, &block)
353
+ ```
264
354
 
265
355
  ### Handling Exceptions
266
356
 
267
- - `SomeJob.retry_on`
268
- - `SomeJob.discard_on`
269
- - `SomeJob.after_discard`
357
+ ```ruby
358
+ SomeJob.retry_on
359
+ SomeJob.discard_on
360
+ SomeJob.after_discard
361
+ ```
270
362
 
271
363
  ### Creating Jobs
272
364
 
@@ -274,35 +366,40 @@ SomeJob.queue_adapter = FooAdapter.new # Uses FooAdapter directly
274
366
  # Create without enqueueing
275
367
  job = SomeJob.new
276
368
  job = SomeJob.new(args)
369
+ job = SomeJob.new.deserialize(other_job.serialize)
277
370
 
278
371
  # Create and enqueue
279
372
  job = SomeJob.perform_later
280
373
  job = SomeJob.perform_later(args)
281
374
 
282
- # Create and run (enqueued on failure)
375
+ # Create without enqueueing and run (only enqueued on failure if retryable)
283
376
  SomeJob.perform_now
284
377
  SomeJob.perform_now(args)
285
378
  ```
286
379
 
287
380
  ### Enqueueing Jobs
288
381
 
382
+ Jobs are enqueued via the `ActiveJob::Base#enqueue` method. This method returns
383
+ the job on success. If an error is raised during enqueueing, that error will
384
+ propagate to the caller, unless the error is an `ActiveJob::EnqueueError`. In
385
+ this case, `enqueue` will return `false` and `job.enqueue_error` will be set.
386
+
289
387
  ```ruby
290
388
  SomeJob.new(args).enqueue
291
389
  SomeJob.new(args).enqueue(options)
292
390
 
391
+ # Via perform_later
293
392
  SomeJob.perform_later(SomeJob.new(args))
294
-
295
393
  SomeJob.perform_later(args)
296
394
  SomeJob.set(options).perform_later(args)
297
395
 
298
- # Enqueued on failure
396
+ # After a failure during execution
299
397
  SomeJob.perform_now(args)
398
+ ActiveJob::Base.execute(SomeJob.new(args).serialize)
300
399
 
301
400
  # Enqueue multiple
302
401
  ActiveJob.perform_all_later(SomeJob.new, SomeJob.new)
303
402
  ActiveJob.perform_all_later(SomeJob.new, SomeJob.new, options:)
304
-
305
- # Enqueue multiple
306
403
  SomeJob.set(options).perform_all_later(SomeJob.new, SomeJob.new)
307
404
  SomeJob.set(options).perform_all_later(SomeJob.new, SomeJob.new, options:)
308
405
  ```
@@ -310,7 +407,7 @@ SomeJob.set(options).perform_all_later(SomeJob.new, SomeJob.new, options:)
310
407
  ### Executing Jobs
311
408
 
312
409
  ```ruby
313
- # Executed without enqueueing, enqueued on failure if retries configured
410
+ # Executed without enqueueing, enqueued on failure if retryable
314
411
  SomeJob.new(args).perform_now
315
412
  SomeJob.perform_now(args)
316
413
  ActiveJob::Base.execute(SomeJob.new(args).serialize)
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marj
4
+ # The interface provided by {Marj} and {Marj::Relation}.
5
+ #
6
+ # To create a custom jobs interface, for example for all job classes in your application:
7
+ # class ApplicationJob < ActiveJob::Base
8
+ # extend Marj::JobsInterface
9
+ #
10
+ # def self.all
11
+ # Marj::Relation.new(self == ApplicationJob ? Marj::Record.ordered : Marj::Record.where(job_class: self))
12
+ # end
13
+ # end
14
+ #
15
+ # class SomeJob < ApplicationJob
16
+ # def perform(msg)
17
+ # puts msg
18
+ # end
19
+ # end
20
+ #
21
+ # This will allow you to query jobs via the +ApplicationJob+ class:
22
+ # ApplicationJob.next # Returns the next job of any type
23
+ #
24
+ # Or to query jobs via a specific job class:
25
+ # SomeJob.next # Returns the next SomeJob
26
+ #
27
+ # Alternatively, to create a jobs interface for a single job class:
28
+ # class SomeJob < ActiveJob::Base
29
+ # extend Marj::JobsInterface
30
+ #
31
+ # def self.all
32
+ # Marj::Relation.new(Marj::Record.where(job_class: self).ordered)
33
+ # end
34
+ # end
35
+ module JobsInterface
36
+ def self.included(clazz)
37
+ return if clazz == Marj::Relation
38
+
39
+ clazz.delegate :queue, :next, :count, :where, :due, :perform_all, :discard_all, to: :all
40
+ end
41
+ private_class_method :included
42
+
43
+ def self.extended(clazz)
44
+ clazz.singleton_class.delegate :queue, :next, :count, :where, :due, :perform_all, :discard_all, to: :all
45
+ end
46
+ private_class_method :extended
47
+
48
+ # Returns a {Marj::Relation} for jobs in the specified queue(s).
49
+ #
50
+ # @param queue [String, Symbol] the queue to query
51
+ # @param queues [Array<String>, Array<Symbol>] more queues to query
52
+ # @return [Marj::Relation]
53
+ def queue(queue, *queues)
54
+ Marj::Relation.new(all.where(queue_name: queues.dup.unshift(queue)))
55
+ end
56
+
57
+ # Returns the next job or the next N jobs if +limit+ is specified. If no jobs exist, returns +nil+.
58
+ #
59
+ # @param limit [Integer, NilClass]
60
+ # @return [ActiveJob::Base, NilClass]
61
+ def next(limit = nil)
62
+ all.first(limit)&.then { _1.is_a?(Array) ? _1.map(&:as_job) : _1.as_job }
63
+ end
64
+
65
+ # Returns a count of jobs, optionally either matching the specified column name criteria or where the specified
66
+ # block returns +true+.
67
+ #
68
+ # @param column_name [String, Symbol, NilClass]
69
+ # @param block [Proc, NilClass]
70
+ # @return [Integer]
71
+ def count(column_name = nil, &block)
72
+ block_given? ? all.count(column_name) { |r| block.call(r.as_job) } : all.count(column_name)
73
+ end
74
+
75
+ # Returns a {Marj::Relation} for jobs matching the specified criteria.
76
+ #
77
+ # @param args [Array]
78
+ # @return [Marj::Relation]
79
+ def where(*args)
80
+ Marj::Relation.new(all.where(*args))
81
+ end
82
+
83
+ # Returns a {Marj::Relation} for enqueued jobs with a +scheduled_at+ that is either +null+ or in the past.
84
+ #
85
+ # @return [Marj::Relation]
86
+ def due
87
+ Marj::Relation.new(all.due)
88
+ end
89
+
90
+ # Calls +perform_now+ on each job.
91
+ #
92
+ # @param batch_size [Integer, NilClass] the number of jobs to fetch at a time, or +nil+ to fetch all jobs at once
93
+ # @return [Array] the results returned by each job
94
+ def perform_all(batch_size: nil)
95
+ if batch_size
96
+ [].tap do |results|
97
+ while (jobs = all.limit(batch_size).map(&:as_job)).any?
98
+ results.concat(jobs.map { |job| ActiveJob::Callbacks.run_callbacks(:execute) { job.perform_now } })
99
+ end
100
+ end
101
+ else
102
+ all.map(&:as_job).map { |job| ActiveJob::Callbacks.run_callbacks(:execute) { job.perform_now } }
103
+ end
104
+ end
105
+
106
+ # Discards all jobs.
107
+ #
108
+ # @return [Numeric] the number of discarded jobs
109
+ def discard_all
110
+ all.delete_all
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job'
4
+ require 'active_record'
5
+
6
+ module Marj
7
+ # The default +ActiveRecord+ class.
8
+ class Record < ActiveRecord::Base
9
+ self.table_name = Marj.table_name
10
+
11
+ # Order by +enqueued_at+ rather than +job_id+ (the default).
12
+ self.implicit_order_column = 'enqueued_at'
13
+
14
+ # Using a custom serializer for exception_executions so that we can interact with it as a hash rather than a
15
+ # string.
16
+ serialize(:exception_executions, coder: JSON)
17
+
18
+ # Using a custom serializer for arguments so that we can interact with as an array rather than a string.
19
+ # This enables code like:
20
+ # Marj::Record.next.arguments.first
21
+ # Marj::Record.next.update!(arguments: ['foo', 1, Time.now])
22
+ serialize(:arguments, coder: Class.new do
23
+ def self.dump(arguments)
24
+ return ActiveJob::Arguments.serialize(arguments).to_json if arguments.is_a?(Array)
25
+ return arguments if arguments.is_a?(String) || arguments.nil?
26
+
27
+ raise "invalid arguments: #{arguments}"
28
+ end
29
+
30
+ def self.load(arguments)
31
+ arguments ? ActiveJob::Arguments.deserialize(JSON.parse(arguments)) : nil
32
+ end
33
+ end)
34
+
35
+ # Using a custom serializer for job_class so that we can interact with it as a class rather than a string.
36
+ serialize(:job_class, coder: Class.new do
37
+ def self.dump(clazz)
38
+ return clazz.name if clazz.is_a?(Class)
39
+ return clazz if clazz.is_a?(String) || clazz.nil?
40
+
41
+ raise "invalid class: #{clazz}"
42
+ end
43
+
44
+ def self.load(str)
45
+ str&.constantize
46
+ end
47
+ end)
48
+
49
+ # Returns a job object for this record which will update the database when successfully executed, enqueued or
50
+ # discarded.
51
+ #
52
+ # @return [ActiveJob::Base]
53
+ def as_job
54
+ Marj.send(:to_job, self)
55
+ end
56
+
57
+ class << self
58
+ # Returns an +ActiveRecord::Relation+ scope for enqueued jobs with a +scheduled_at+ that is either +null+ or in
59
+ # the past.
60
+ #
61
+ # @return [ActiveRecord::Relation]
62
+ def due
63
+ where('scheduled_at IS NULL OR scheduled_at <= ?', Time.now.utc)
64
+ end
65
+
66
+ # Returns an +ActiveRecord::Relation+ scope for jobs ordered by +priority+ (+null+ last), then +scheduled_at+
67
+ # (+null+ last), then +enqueued_at+.
68
+ #
69
+ # @return [ActiveRecord::Relation]
70
+ def ordered
71
+ order(
72
+ Arel.sql(<<~SQL.squish, Time.now.utc)
73
+ CASE WHEN scheduled_at IS NULL OR scheduled_at <= ? THEN 0 ELSE 1 END,
74
+ CASE WHEN priority IS NULL THEN 1 ELSE 0 END, priority,
75
+ CASE WHEN scheduled_at IS NULL THEN 1 ELSE 0 END, scheduled_at,
76
+ enqueued_at
77
+ SQL
78
+ )
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'jobs_interface'
4
+
5
+ module Marj
6
+ # Returned by {Marj::JobsInterface} query methods to enable chaining and +Enumerable+ methods.
7
+ class Relation
8
+ include Enumerable
9
+ include Marj::JobsInterface
10
+
11
+ attr_reader :all
12
+ private :all
13
+
14
+ # Returns a {Marj::Relation} which wraps the specified +ActiveRecord+ relation.
15
+ def initialize(ar_relation)
16
+ @all = ar_relation
17
+ end
18
+
19
+ # Yields each job in this relation.
20
+ #
21
+ # @param block [Proc]
22
+ # @return [Array] the jobs in this relation
23
+ def each(&block)
24
+ all.map(&:as_job).each(&block)
25
+ end
26
+
27
+ # Provides +pretty_inspect+ output containing arrays of jobs rather than arrays of records, similar to the output
28
+ # produced when calling +pretty_inspect+ on +ActiveRecord::Relation+.
29
+ #
30
+ # Instead of the default +pretty_inspect+ output:
31
+ # > Marj.all
32
+ # =>
33
+ # #<Marj::Relation:0x000000012728bd88
34
+ # @ar_relation=
35
+ # [#<Marj::Record:0x0000000126c42080
36
+ # job_id: "1382cb98-c518-46ca-a0cc-d831e11a0714",
37
+ # job_class: TestJob,
38
+ # arguments: ["foo"],
39
+ # queue_name: "default",
40
+ # priority: nil,
41
+ # executions: 0,
42
+ # exception_executions: {},
43
+ # enqueued_at: 2024-01-25 15:31:06.115773 UTC,
44
+ # scheduled_at: nil,
45
+ # locale: "en",
46
+ # timezone: "UTC">]>
47
+ #
48
+ # Produces:
49
+ # > Marj.all
50
+ # =>
51
+ # [#<TestJob:0x000000010b63cef8
52
+ # @_scheduled_at_time=nil,
53
+ # @arguments=[],
54
+ # @enqueued_at=2024-01-25 15:31:06 UTC,
55
+ # @exception_executions={},
56
+ # @executions=0,
57
+ # @job_id="1382cb98-c518-46ca-a0cc-d831e11a0714",
58
+ # @locale="en",
59
+ # @priority=nil,
60
+ # @provider_job_id=nil,
61
+ # @queue_name="default",
62
+ # @scheduled_at=nil,
63
+ # @serialized_arguments=["foo"],
64
+ # @timezone="UTC">]
65
+ #
66
+ # @param pp [PP]
67
+ # @return [NilClass]
68
+ def pretty_print(pp)
69
+ pp.pp(to_a)
70
+ end
71
+ end
72
+ end
data/lib/marj.rb CHANGED
@@ -1,8 +1,156 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'marj_adapter'
4
+ require_relative 'marj/jobs_interface'
5
+ require_relative 'marj/relation'
6
+
7
+ # A minimal database-backed ActiveJob queueing backend.
8
+ #
9
+ # The {Marj} module provides methods for interacting with enqueued jobs. These methods accept, return and yield
10
+ # +ActiveJob+ objects rather than +ActiveRecord+ objects. Returned jobs are ordered by due date. To query the database
11
+ # directly, use {Record}.
12
+ #
13
+ # Example usage:
14
+ # Marj.all # Returns all enqueued jobs.
15
+ # Marj.queue # Returns jobs in the specified queue(s).
16
+ # Marj.due # Returns jobs which are due to be executed.
17
+ # Marj.next # Returns the next job(s) to be executed.
18
+ # Marj.count # Returns the number of enqueued jobs.
19
+ # Marj.where # Returns jobs matching the specified criteria.
20
+ # Marj.perform_all # Executes all jobs.
21
+ # Marj.discard_all # Discards all jobs.
22
+ # Marj.discard # Discards the specified job.
23
+ #
24
+ # Query methods can also be chained:
25
+ # Marj.due.where(job_class: SomeJob).next # Returns the next SomeJob that is due
26
+ #
27
+ # Note that by default, Marj uses {Marj::Record} to interact with the +jobs+ table. To use a different record class, set
28
+ # {record_class}. To simply override the table name, set {table_name} before loading +ActiveRecord+.
29
+ #
3
30
  # See https://github.com/nicholasdower/marj
31
+ module Marj
32
+ # The Marj version.
33
+ VERSION = '4.0.0'
4
34
 
5
- require_relative 'marj_adapter'
6
- require_relative 'marj_config'
35
+ Kernel.autoload(:Record, File.expand_path(File.join('marj', 'record.rb'), __dir__))
36
+
37
+ @table_name = :jobs
38
+ @record_class = 'Marj::Record'
39
+
40
+ class << self
41
+ include Marj::JobsInterface
42
+
43
+ # @!attribute record_class
44
+ # The name of the +ActiveRecord+ class. Defaults to +Marj::Record+.
45
+ # @return [Class, String]
46
+
47
+ attr_writer :record_class
48
+
49
+ def record_class
50
+ @record_class = @record_class.is_a?(String) ? @record_class.constantize : @record_class
51
+ end
52
+
53
+ # @!attribute table_name
54
+ # The name of the database table. Defaults to +:jobs+.
55
+ # @return [Symbol, String]
56
+ attr_accessor :table_name
57
+
58
+ # Returns a {Marj::Relation} for all jobs in the order they should be executed.
59
+ #
60
+ # @return [Marj::Relation]
61
+ def all
62
+ Marj::Relation.new(Marj.record_class.ordered)
63
+ end
64
+
65
+ # Discards the specified job.
66
+ #
67
+ # @return [Integer] the number of discarded jobs
68
+ def discard(job)
69
+ all.where(job_id: job.job_id).discard_all
70
+ end
71
+
72
+ private
73
+
74
+ # Creates a job instance for the given record which will update the database when successfully executed, enqueued or
75
+ # discarded.
76
+ #
77
+ # @param record [ActiveRecord::Base]
78
+ # @return [ActiveJob::Base] the new job instance
79
+ def to_job(record)
80
+ # See register_callbacks for details on how callbacks are used.
81
+ job = record.job_class.new.tap { register_callbacks(_1, record) }
82
+
83
+ # ActiveJob::Base#deserialize expects serialized arguments. But the record arguments have already been
84
+ # deserialized by a custom ActiveRecord serializer (see below). So instead we use the raw arguments string.
85
+ job_data = record.attributes.merge('arguments' => JSON.parse(record.read_attribute_before_type_cast(:arguments)))
86
+
87
+ # ActiveJob::Base#deserialize expects dates to be strings rather than Time objects.
88
+ job_data = job_data.to_h { |k, v| [k, %w[enqueued_at scheduled_at].include?(k) ? v&.iso8601 : v] }
89
+
90
+ job.tap { job.deserialize(job_data) }
91
+ end
92
+
93
+ # Registers callbacks for the given job which destroy the given database record when the job succeeds or is
94
+ # discarded.
95
+ #
96
+ # @param job [ActiveJob::Base]
97
+ # @param record [ActiveRecord::Base]
98
+ # @return [ActiveJob::Base]
99
+ def register_callbacks(job, record)
100
+ raise 'callbacks already registered' if job.singleton_class.instance_variable_get(:@record)
101
+
102
+ # We need to detect three cases:
103
+ # - If a job succeeds, after_perform will be called.
104
+ # - If a job fails and should be retried, enqueue will be called. This is handled by the enqueue method.
105
+ # - If a job exceeds its max attempts, after_discard will be called.
106
+ job.singleton_class.after_perform { |_j| record.destroy! }
107
+ job.singleton_class.after_discard { |_j, _exception| record.destroy! }
108
+ job.singleton_class.instance_variable_set(:@record, record)
109
+
110
+ job
111
+ end
112
+
113
+ # Enqueue a job for execution at the specified time.
114
+ #
115
+ # @param job [ActiveJob::Base] the job to enqueue
116
+ # @param record_class [Class] the +ActiveRecord+ class
117
+ # @param time [Time, NilClass] optional time at which to execute the job
118
+ # @return [ActiveJob::Base] the enqueued job
119
+ def enqueue(job, record_class, time = nil)
120
+ job.scheduled_at = time
121
+ # Argument serialization is done by ActiveJob. ActiveRecord expects deserialized arguments.
122
+ serialized = job.serialize.symbolize_keys!.without(:provider_job_id).merge(arguments: job.arguments)
7
123
 
8
- Kernel.autoload(:Marj, File.expand_path('marj_record.rb', __dir__))
124
+ # When a job is enqueued, we must create/update the corresponding database record. We also must ensure callbacks
125
+ # are registered on the job instance so that when the job is executed, the database record is deleted or updated
126
+ # (depending on the result).
127
+ #
128
+ # We keep track of whether callbacks have been registered by setting the @record instance variable on the job's
129
+ # singleton class. This holds a reference to the record. This ensures that if execute is called on a record
130
+ # instance, any updates to the database are reflected on that record instance.
131
+ if (existing_record = job.singleton_class.instance_variable_get(:@record))
132
+ # This job instance has already been associated with a database row.
133
+ if record_class.exists?(job_id: job.job_id)
134
+ # The database row still exists, we simply need to update it.
135
+ existing_record.update!(serialized)
136
+ else
137
+ # Someone else deleted the database row, we need to recreate and reload the existing record instance. We don't
138
+ # want to register the new instance because someone might still have a reference to the existing one.
139
+ record_class.create!(serialized)
140
+ existing_record.reload
141
+ end
142
+ else
143
+ # This job instance has not been associated with a database row.
144
+ if (new_record = record_class.find_by(job_id: job.job_id))
145
+ # The database row already exists. Update it.
146
+ new_record.update!(serialized)
147
+ else
148
+ # The database row does not exist. Create it.
149
+ new_record = record_class.create!(serialized)
150
+ end
151
+ register_callbacks(job, new_record)
152
+ end
153
+ job
154
+ end
155
+ end
156
+ end
data/lib/marj_adapter.rb CHANGED
@@ -4,12 +4,19 @@
4
4
  #
5
5
  # See https://github.com/nicholasdower/marj
6
6
  class MarjAdapter
7
+ # Creates a new adapter which will enqueue jobs using the given +ActiveRecord+ class.
8
+ #
9
+ # @param record_class [Class, String] the +ActiveRecord+ class (or its name) to use to store jobs
10
+ def initialize(record_class = 'Marj::Record')
11
+ @record_class = record_class
12
+ end
13
+
7
14
  # Enqueue a job for immediate execution.
8
15
  #
9
16
  # @param job [ActiveJob::Base] the job to enqueue
10
17
  # @return [ActiveJob::Base] the enqueued job
11
18
  def enqueue(job)
12
- Marj.send(:enqueue, job)
19
+ Marj.send(:enqueue, job, record_class)
13
20
  end
14
21
 
15
22
  # Enqueue a job for execution at the specified time.
@@ -18,6 +25,12 @@ class MarjAdapter
18
25
  # @param timestamp [Numeric, NilClass] optional number of seconds since Unix epoch at which to execute the job
19
26
  # @return [ActiveJob::Base] the enqueued job
20
27
  def enqueue_at(job, timestamp)
21
- Marj.send(:enqueue, job, timestamp ? Time.at(timestamp).utc : nil)
28
+ Marj.send(:enqueue, job, record_class, timestamp ? Time.at(timestamp).utc : nil)
29
+ end
30
+
31
+ private
32
+
33
+ def record_class
34
+ @record_class = @record_class.is_a?(String) ? @record_class.constantize : @record_class
22
35
  end
23
36
  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: 2.1.0
4
+ version: 4.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-24 00:00:00.000000000 Z
11
+ date: 2024-01-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -38,7 +38,7 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '7.1'
41
- description: Marj (Minimal ActiveRecord Jobs) is the simplest database-backed ActiveJob
41
+ description: Marj (Minimal ActiveRecord Jobs) is a minimal database-backed ActiveJob
42
42
  queueing backend.
43
43
  email: nicholasdower@gmail.com
44
44
  executables: []
@@ -48,16 +48,17 @@ files:
48
48
  - LICENSE.txt
49
49
  - README.md
50
50
  - lib/marj.rb
51
+ - lib/marj/jobs_interface.rb
52
+ - lib/marj/record.rb
53
+ - lib/marj/relation.rb
51
54
  - lib/marj_adapter.rb
52
- - lib/marj_config.rb
53
- - lib/marj_record.rb
54
55
  homepage: https://github.com/nicholasdower/marj
55
56
  licenses:
56
57
  - MIT
57
58
  metadata:
58
59
  bug_tracker_uri: https://github.com/nicholasdower/marj/issues
59
- changelog_uri: https://github.com/nicholasdower/marj/releases/tag/v2.1.0
60
- documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/v2.1.0
60
+ changelog_uri: https://github.com/nicholasdower/marj/releases/tag/v4.0.0
61
+ documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/v4.0.0
61
62
  homepage_uri: https://github.com/nicholasdower/marj
62
63
  rubygems_mfa_required: 'true'
63
64
  source_code_uri: https://github.com/nicholasdower/marj
data/lib/marj_config.rb DELETED
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Marj configuration.
4
- #
5
- # See https://github.com/nicholasdower/marj
6
- class MarjConfig
7
- @table_name = 'jobs'
8
-
9
- class << self
10
- # The name of the database table. Defaults to "jobs".
11
- #
12
- # @return [String]
13
- attr_accessor :table_name
14
- end
15
- end
data/lib/marj_record.rb DELETED
@@ -1,157 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'active_job'
4
- require 'active_record'
5
- require_relative 'marj_config'
6
-
7
- # The Marj ActiveRecord model class.
8
- #
9
- # See https://github.com/nicholasdower/marj
10
- class Marj < ActiveRecord::Base
11
- # The Marj version.
12
- VERSION = '2.1.0'
13
-
14
- # Executes the job associated with this record and returns the result.
15
- def execute
16
- # Normally we would call ActiveJob::Base#execute which has the following implementation:
17
- # ActiveJob::Callbacks.run_callbacks(:execute) do
18
- # job = deserialize(job_data)
19
- # job.perform_now
20
- # end
21
- # However, we need to instantiate the job ourselves in order to register callbacks before execution.
22
- ActiveJob::Callbacks.run_callbacks(:execute) do
23
- # See register_callbacks for details on how callbacks are used.
24
- job = job_class.new.tap { Marj.send(:register_callbacks, _1, self) }
25
-
26
- # ActiveJob::Base#deserialize expects serialized arguments. But the record arguments have already been
27
- # deserialized by a custom ActiveRecord serializer (see below). So instead we use the raw arguments string.
28
- job_data = attributes.merge('arguments' => JSON.parse(read_attribute_before_type_cast(:arguments)))
29
-
30
- # ActiveJob::Base#deserialize expects dates to be strings rather than Time objects.
31
- job_data = job_data.to_h { |k, v| [k, %w[enqueued_at scheduled_at].include?(k) ? v&.iso8601 : v] }
32
-
33
- job.deserialize(job_data)
34
- job.perform_now
35
- end
36
- end
37
-
38
- # Returns an ActiveRecord::Relation scope for enqueued jobs with a +scheduled_at+ that is either +null+ or in the
39
- # past. Jobs are ordered by +priority+ (+null+ last), then +scheduled_at+ (+null+ last), then +enqueued_at+.
40
- #
41
- # @return [ActiveRecord::Relation]
42
- def self.ready
43
- where('scheduled_at is null or scheduled_at <= ?', Time.now.utc).order(
44
- Arel.sql(<<~SQL.squish)
45
- CASE WHEN priority IS NULL THEN 1 ELSE 0 END, priority,
46
- CASE WHEN scheduled_at IS NULL THEN 1 ELSE 0 END, scheduled_at,
47
- enqueued_at
48
- SQL
49
- )
50
- end
51
-
52
- self.table_name = MarjConfig.table_name
53
-
54
- # Order by +enqueued_at+ rather than +job_id+ (the default)
55
- self.implicit_order_column = 'enqueued_at'
56
-
57
- # Using a custom serializer for exception_executions so that we can interact with it as a hash rather than a string.
58
- serialize(:exception_executions, coder: JSON)
59
-
60
- # Using a custom serializer for arguments so that we can interact with as an array rather than a string.
61
- # This enables code like:
62
- # Marj.first.arguments.first
63
- # Marj.first.update!(arguments: ['foo', 1, Time.now])
64
- serialize(:arguments, coder: Class.new do
65
- def self.dump(arguments)
66
- return ActiveJob::Arguments.serialize(arguments).to_json if arguments.is_a?(Array)
67
- return arguments if arguments.is_a?(String) || arguments.nil?
68
-
69
- raise "invalid arguments: #{arguments}"
70
- end
71
-
72
- def self.load(arguments)
73
- arguments ? ActiveJob::Arguments.deserialize(JSON.parse(arguments)) : nil
74
- end
75
- end)
76
-
77
- # Using a custom serializer for job_class so that we can interact with it as a class rather than a string.
78
- serialize(:job_class, coder: Class.new do
79
- def self.dump(clazz)
80
- return clazz.name if clazz.is_a?(Class)
81
- return clazz if clazz.is_a?(String) || clazz.nil?
82
-
83
- raise "invalid class: #{clazz}"
84
- end
85
-
86
- def self.load(str)
87
- str&.constantize
88
- end
89
- end)
90
-
91
- # Registers job callbacks used to keep the database record for the specified job in sync.
92
- #
93
- # @param job [ActiveJob::Base]
94
- # @return [ActiveJob::Base]
95
- def self.register_callbacks(job, record)
96
- if job.singleton_class.instance_variable_get(:@__marj)
97
- # Callbacks already registered. We just need to update the record.
98
- job.singleton_class.instance_variable_set(:@__marj, record)
99
- return
100
- end
101
-
102
- # We need to detect three cases:
103
- # - If a job succeeds, after_perform will be called.
104
- # - If a job fails and should be retried, enqueue will be called. This is handled by the enqueue method.
105
- # - If a job exceeds its max attempts, after_discard will be called.
106
- job.singleton_class.after_perform { |_j| job.singleton_class.instance_variable_get(:@__marj).destroy! }
107
- job.singleton_class.after_discard { |_j, _exception| job.singleton_class.instance_variable_get(:@__marj).destroy! }
108
- job.singleton_class.instance_variable_set(:@__marj, record)
109
-
110
- job
111
- end
112
- private_class_method :register_callbacks
113
-
114
- # Enqueue a job for execution at the specified time.
115
- #
116
- # @param job [ActiveJob::Base] the job to enqueue
117
- # @param time [Time, NilClass] optional time at which to execute the job
118
- # @return [ActiveJob::Base] the enqueued job
119
- def self.enqueue(job, time = nil)
120
- job.scheduled_at = time
121
- # Argument serialization is done by ActiveJob. ActiveRecord expects deserialized arguments.
122
- serialized = job.serialize.symbolize_keys!.without(:provider_job_id).merge(arguments: job.arguments)
123
-
124
- # When a job is enqueued, we must create/update the corresponding database record. We also must ensure callbacks are
125
- # registered on the job instance so that when the job is executed, the database record is deleted or updated
126
- # (depending on the result).
127
- #
128
- # There are two normal cases:
129
- # - The first time a job is enqueued, we need to create the record and register callbacks.
130
- # - If a previously enqueued job instance is re-enqueued, for instance after execution fails, callbacks have
131
- # already been registered. In this case we only need to update the record.
132
- #
133
- # We keep track of whether callbacks have been registered by setting the @__marj instance variable on the job's
134
- # singleton class. This holds a reference to the record. This allows us to update the record without re-fetching it
135
- # and also ensures that if execute is called on a record any updates to the database are reflected on that record
136
- # instance.
137
- #
138
- # There are also two edge cases:
139
- # - It is possible for new job instance to be created for a job that is already in the database. In this case
140
- # we need to update the record and register callbacks.
141
- # - It is possible for the underlying row corresponding to an existing job to have been deleted. In this case we
142
- # need to create a new record and update the reference on the job's singleton class.
143
- if (record = job.singleton_class.instance_variable_get(:@__marj))
144
- if Marj.exists?(job_id: job.job_id)
145
- record.update!(serialized)
146
- else
147
- record = Marj.create!(serialized)
148
- register_callbacks(job, record)
149
- end
150
- else
151
- record = Marj.find_or_create_by!(job_id: job.job_id) { _1.assign_attributes(serialized) }
152
- register_callbacks(job, record)
153
- end
154
- job
155
- end
156
- private_class_method :enqueue
157
- end