marj 2.1.0 → 3.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: '078c3d48b27784e09425ba8eda2207038d6ce5c7a711953e8567c76aac689435'
4
- data.tar.gz: c129b4848de1e2fe55de0de3a4893586a052cd814df798879dc196a81c1a94d5
3
+ metadata.gz: 7afb73261695ca8eb0005d59b292ec659a9c10124a2b3984242e3f8e209fc897
4
+ data.tar.gz: 5f5fea852ab9a8e7eb9ca6fa59ed6cb4e0537651bf3edf6713ec0cad4a1c1ed0
5
5
  SHA512:
6
- metadata.gz: 4cef48fb7c862123f4a5ad7ed5bf7a9d95eb3d7036831db79ad3dc9cf94f074a112ce58f0189484f991a52185cdf8e8ceed48dd9237bf4a04d5e3bbe30b2d274
7
- data.tar.gz: 7631b1a0fef1ece1edcf15541dc85d25b2d5d3ba1cb8ca716133975126e5ce9bb4f28d627a4c2a5d751029ce8f67ba4db47c6fa9d197467fade4c09d20a04716
6
+ metadata.gz: 348506cb34c7956e4c17554223c81d47353905ebdfb684c965d4deeb2b3a47727b5cb971211e2f2c48e039c8c5c1d9a1b02a6e9647be247ca0d86472a357f1b0
7
+ data.tar.gz: 94069a23d1882ff62c5b630e5e50357c63f230f8fc4a86828d3e4d785402f6c325fb82fb2d398c5ec937a572018b8c4027f5569fb818dcbc1dd2258e1cc9ac54
data/README.md CHANGED
@@ -1,6 +1,6 @@
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
 
@@ -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` model class is provided to query the database directly.
19
21
 
20
22
  ## Features Not Provided
21
23
 
@@ -24,38 +26,20 @@ 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
- ### 3. Create the database table
42
+ ### 3. Create the jobs table
59
43
 
60
44
  ```ruby
61
45
  class CreateJobs < ActiveRecord::Migration[7.1]
@@ -89,18 +73,56 @@ end
89
73
  ```ruby
90
74
  require 'marj'
91
75
 
92
- # With rails:
93
- class MyApplication < Rails::Application
94
- config.active_job.queue_adapter = :marj
95
- end
76
+ Rails.configuration.active_job.queue_adapter = :marj # Globally, with Rails
77
+ ActiveJob::Base.queue_adapter = :marj # Globally, without Rails
78
+ SomeJob.queue_adapter = :marj # Single job
79
+ ```
80
+
81
+ ## Jobs Interface
82
+
83
+ `Marj::Jobs` provides a query interface (`Marj::JobsInterface`) which can be
84
+ used to retrieve, execute and discard enqueued jobs. It returns, yields and
85
+ accepts `ActiveJob` objects rather than `ActiveRecord` objects. Jobs are
86
+ orderd by due date. To query the database directly, use `Marj::Record`.
96
87
 
97
- # Without Rails:
98
- ActiveJob::Base.queue_adapter = :marj
88
+ ```ruby
89
+ Marj::Jobs.all # Returns all enqueued jobs.
90
+ Marj::Jobs.queue # Returns jobs in the specified queue(s).
91
+ Marj::Jobs.due # Returns jobs which are due to be executed.
92
+ Marj::Jobs.next # Returns the next job(s) to be executed.
93
+ Marj::Jobs.count # Returns the number of enqueued jobs.
94
+ Marj::Jobs.where # Returns jobs matching the specified criteria.
95
+ Marj::Jobs.perform_all # Executes all jobs.
96
+ Marj::Jobs.discard_all # Discards all jobs.
97
+ Marj::Jobs.discard # Discards the specified job.
98
+ ```
99
+
100
+ `all`, `queue`, `due` and `where` return a `Marj::Relation` which provides
101
+ the same `Marj::JobsInterface`. This can be used to chain query methods like:
102
+
103
+ ```ruby
104
+ Marj::Jobs.due.where(job_class: SomeJob).next
105
+ ```
99
106
 
100
- # Or for specific jobs (with or without Rails):
101
- class SomeJob < ActiveJob::Base
102
- self.queue_adapter = :marj
107
+ Note that the `Marj::JobsInterface` can be added to any class or module. For
108
+ example, to add it to all jobs classes:
109
+
110
+ ```ruby
111
+ class ApplicationJob < ActiveJob::Base
112
+ extend Marj::JobsInterface
113
+
114
+ def self.all
115
+ Marj::Relation.new(
116
+ self == ApplicationJob ?
117
+ Marj::Record.ordered : Marj::Record.where(job_class: self)
118
+ )
119
+ end
103
120
  end
121
+
122
+ class SomeJob < ApplicationJob; end
123
+
124
+ ApplicationJob.due # Returns all jobs which are due to be executed.
125
+ SomeJob.due # Returns SomeJobs which are due to be executed.
104
126
  ```
105
127
 
106
128
  ## Example Usage
@@ -110,35 +132,96 @@ end
110
132
  job = SomeJob.perform_later('foo')
111
133
  job.perform_now
112
134
 
113
- # Enqueue, retrieve and manually run a job:
114
- SomeJob.perform_later('foo')
115
- Marj.first.execute
135
+ # Retrieve and execute a job
136
+ Marj::Jobs.due.next.perform_now
116
137
 
117
- # Run all ready jobs:
118
- Marj.ready.each(&:execute)
138
+ # Run all due jobs (single DB query)
139
+ Marj::Jobs.due.perform_all
119
140
 
120
- # Run all ready jobs, querying each time:
121
- loop { Marj.ready.first&.tap(&:execute) || break }
141
+ # Run all due jobs (multiple DB queries)
142
+ Marj::Jobs.due.perform_all(batch_size: 1)
122
143
 
123
- # Run all ready jobs in a specific queue:
124
- loop { Marj.where(queue_name: 'foo').ready.first&.tap(&:execute) || break }
144
+ # Run all due jobs in a specific queue:
145
+ Marj::Jobs.queue('foo').due.perform_all
125
146
 
126
- # Run jobs as they become ready:
147
+ # Run all jobs indefinitely, as they become due:
127
148
  loop do
128
- loop { Marj.ready.first&.tap(&:execute) || break }
129
- rescue Exception => e
130
- logger.error(e)
149
+ Marj::Jobs.due.perform_all rescue logger.error($!)
131
150
  ensure
132
151
  sleep 5.seconds
133
152
  end
134
153
  ```
135
154
 
155
+ ## Customization
156
+
157
+ It is possible to create a custom record class and jobs interface. This enables,
158
+ for instance, writing jobs to multiple databases/tables within a single
159
+ application.
160
+
161
+ ```
162
+ class CreateMyJobs < ActiveRecord::Migration[7.1]
163
+ def self.up
164
+ create_table :my_jobs, id: :string, primary_key: :job_id do |table|
165
+ table.string :job_class, null: false
166
+ table.text :arguments, null: false
167
+ table.string :queue_name, null: false
168
+ table.integer :priority
169
+ table.integer :executions, null: false
170
+ table.text :exception_executions, null: false
171
+ table.datetime :enqueued_at, null: false
172
+ table.datetime :scheduled_at
173
+ table.string :locale, null: false
174
+ table.string :timezone, null: false
175
+ end
176
+
177
+ add_index :my_jobs, %i[enqueued_at]
178
+ add_index :my_jobs, %i[scheduled_at]
179
+ add_index :my_jobs, %i[priority scheduled_at enqueued_at]
180
+ end
181
+
182
+ def self.down
183
+ drop_table :my_jobs
184
+ end
185
+ end
186
+
187
+ class MyRecord < ActiveRecord::Base
188
+ include Marj::RecordInterface
189
+
190
+ self.table_name = 'my_jobs'
191
+ end
192
+
193
+ CreateMyJobs.migrate(:up)
194
+
195
+ class ApplicationJob < ActiveJob::Base
196
+ self.queue_adapter = MarjAdapter.new('MyRecord')
197
+
198
+ extend Marj::JobsInterface
199
+
200
+ def self.all
201
+ Marj::Relation.new(
202
+ self == ApplicationJob ?
203
+ MyRecord.ordered : MyRecord.where(job_class: self)
204
+ )
205
+ end
206
+ end
207
+
208
+ class MyJob < ApplicationJob
209
+ def perform(msg)
210
+ puts msg
211
+ end
212
+ end
213
+
214
+ MyJob.perform_later('oh, hi')
215
+ MyJob.due.next.perform_now
216
+ ```
217
+
136
218
  ## Testing
137
219
 
138
- By default, jobs enqeued during tests will be written to the database. Enqueued jobs can be executed via:
220
+ By default, jobs enqeued during tests will be written to the database. Enqueued
221
+ jobs can be executed via:
139
222
 
140
223
  ```ruby
141
- Marj.ready.each(&:execute)
224
+ Marj::Jobs.due.perform_all
142
225
  ```
143
226
 
144
227
  Alternatively, to use [ActiveJob::QueueAdapters::TestAdapter](https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/TestAdapter.html):
@@ -158,7 +241,9 @@ class ApplicationJob < ActiveJob::Base
158
241
 
159
242
  around_perform do |job, block|
160
243
  if (timeout = job.class.instance_variable_get(:@timeout))
161
- ::Timeout.timeout(timeout, StandardError, 'execution expired') { block.call }
244
+ ::Timeout.timeout(timeout, StandardError, 'execution expired') do
245
+ block.call
246
+ end
162
247
  else
163
248
  block.call
164
249
  end
@@ -185,7 +270,9 @@ class ApplicationJob < ActiveJob::Base
185
270
  def last_error=(error)
186
271
  if error.is_a?(Exception)
187
272
  backtrace = error.backtrace&.map { |line| "\t#{line}" }&.join("\n")
188
- error = backtrace ? "#{error.class}: #{error.message}\n#{backtrace}" : "#{error.class}: #{error.message}"
273
+ error = backtrace ?
274
+ "#{error.class}: #{error.message}\n#{backtrace}" :
275
+ "#{error.class}: #{error.message}"
189
276
  end
190
277
 
191
278
  @last_error = error&.truncate(10_000, omission: '… (truncated)')
@@ -217,10 +304,8 @@ For more information on ActiveJob, see:
217
304
 
218
305
  ```ruby
219
306
  # 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
307
+ Rails.configuration.active_job.queue_adapter = :foo # Instantiates FooAdapter
308
+ Rails.configuration.active_job.queue_adapter = FooAdapter.new
224
309
 
225
310
  # Without Rails
226
311
  ActiveJob::Base.queue_adapter = :foo # Instantiates FooAdapter
@@ -237,11 +322,11 @@ SomeJob.queue_adapter = FooAdapter.new # Uses FooAdapter directly
237
322
  - `config.active_job.queue_name_prefix`
238
323
  - `config.active_job.queue_name_delimiter`
239
324
  - `config.active_job.retry_jitter`
325
+ - `SomeJob.queue_name`
326
+ - `SomeJob.queue_as`
240
327
  - `SomeJob.queue_name_prefix`
241
328
  - `SomeJob.queue_name_delimiter`
242
329
  - `SomeJob.retry_jitter`
243
- - `SomeJob.queue_name`
244
- - `SomeJob.queue_as`
245
330
 
246
331
  ### Options
247
332
 
@@ -274,35 +359,40 @@ SomeJob.queue_adapter = FooAdapter.new # Uses FooAdapter directly
274
359
  # Create without enqueueing
275
360
  job = SomeJob.new
276
361
  job = SomeJob.new(args)
362
+ job = SomeJob.new.deserialize(other_job.serialize)
277
363
 
278
364
  # Create and enqueue
279
365
  job = SomeJob.perform_later
280
366
  job = SomeJob.perform_later(args)
281
367
 
282
- # Create and run (enqueued on failure)
368
+ # Create without enqueueing and run (only enqueued on failure if retryable)
283
369
  SomeJob.perform_now
284
370
  SomeJob.perform_now(args)
285
371
  ```
286
372
 
287
373
  ### Enqueueing Jobs
288
374
 
375
+ Jobs are enqueued via the `ActiveJob::Base#enqueue` method. This method returns
376
+ the job on success. If an error is raised during enqueueing, that error will
377
+ propagate to the caller, unless the error is an `ActiveJob::EnqueueError`. In
378
+ this case, `enqueue` will return `false` and `job.enqueue_error` will be set.
379
+
289
380
  ```ruby
290
381
  SomeJob.new(args).enqueue
291
382
  SomeJob.new(args).enqueue(options)
292
383
 
384
+ # Via perform_later
293
385
  SomeJob.perform_later(SomeJob.new(args))
294
-
295
386
  SomeJob.perform_later(args)
296
387
  SomeJob.set(options).perform_later(args)
297
388
 
298
- # Enqueued on failure
389
+ # After a failure during execution
299
390
  SomeJob.perform_now(args)
391
+ ActiveJob::Base.execute(SomeJob.new(args).serialize)
300
392
 
301
393
  # Enqueue multiple
302
394
  ActiveJob.perform_all_later(SomeJob.new, SomeJob.new)
303
395
  ActiveJob.perform_all_later(SomeJob.new, SomeJob.new, options:)
304
-
305
- # Enqueue multiple
306
396
  SomeJob.set(options).perform_all_later(SomeJob.new, SomeJob.new)
307
397
  SomeJob.set(options).perform_all_later(SomeJob.new, SomeJob.new, options:)
308
398
  ```
@@ -310,7 +400,7 @@ SomeJob.set(options).perform_all_later(SomeJob.new, SomeJob.new, options:)
310
400
  ### Executing Jobs
311
401
 
312
402
  ```ruby
313
- # Executed without enqueueing, enqueued on failure if retries configured
403
+ # Executed without enqueueing, enqueued on failure if retryable
314
404
  SomeJob.new(args).perform_now
315
405
  SomeJob.perform_now(args)
316
406
  ActiveJob::Base.execute(SomeJob.new(args).serialize)
data/lib/marj/jobs.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'jobs_interface'
4
+ require_relative 'relation'
5
+
6
+ module Marj
7
+ # Provides methods for querying, performing and discarding jobs. Returns, yields and accepts
8
+ # +ActiveJob+ objects rather than +ActiveRecord+ objects. To query the database directly, use
9
+ # {Marj::Record}.
10
+ #
11
+ # To create a custom jobs interface, see {Marj::JobsInterface}.
12
+ module Jobs
13
+ singleton_class.include Marj::JobsInterface
14
+
15
+ # Returns a {Marj::Relation} for all jobs in the order they should be executed.
16
+ #
17
+ # @return [Marj::Relation]
18
+ def self.all
19
+ Marj::Relation.new(Marj::Record.ordered)
20
+ end
21
+
22
+ # Discards the specified job.
23
+ #
24
+ # @return [Integer] the number of discarded jobs
25
+ def self.discard(job)
26
+ all.where(job_id: job.job_id).discard_all
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marj
4
+ # The interface provided by {Marj::Jobs} and {Marj::Relation}. Include to create a custom jobs interface.
5
+ #
6
+ # To create a jobs interface for all job classes:
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
+ # ApplicationJob.next
16
+ # SomeJob.next
17
+ #
18
+ # To create a jobs interface for a single job class:
19
+ # class SomeJob < ActiveJob::Base
20
+ # extend Marj::JobsInterface
21
+ #
22
+ # def self.all
23
+ # Marj::Relation.new(Marj::Record.where(job_class: self).ordered)
24
+ # end
25
+ # end
26
+ #
27
+ # SomeJob.next
28
+ module JobsInterface
29
+ # Returns a {Marj::Relation} for jobs in the specified queue(s).
30
+ #
31
+ # @param queue [String, Symbol] the queue to query
32
+ # @param queues [Array<String, Array<Symbol>] more queues to query
33
+ # @return [Marj::Relation]
34
+ def queue(queue, *queues)
35
+ all.queue(queue, *queues)
36
+ end
37
+
38
+ # Returns the next job or the next N jobs if +limit+ is specified. If no jobs exist, returns +nil+.
39
+ #
40
+ # @param limit [Integer, NilClass]
41
+ # @return [ActiveJob::Base, NilClass]
42
+ def next(limit = nil)
43
+ all.next(limit)
44
+ end
45
+
46
+ # Returns a count of jobs, optionally either matching the specified column name criteria or where the specified
47
+ # block returns +true+.
48
+ #
49
+ # @param column_name [String, Symbol, NilClass]
50
+ # @param block [Proc, NilClass]
51
+ # @return [Integer]
52
+ def count(column_name = nil, &block)
53
+ all.count(column_name, &block)
54
+ end
55
+
56
+ # Returns a {Marj::Relation} for jobs matching the specified criteria.
57
+ #
58
+ # @param args [Array]
59
+ # @return [Marj::Relation]
60
+ def where(*args)
61
+ all.where(*args)
62
+ end
63
+
64
+ # Returns a {Marj::Relation} for enqueued jobs with a +scheduled_at+ that is either +null+ or in the past.
65
+ #
66
+ # @return [Marj::Relation]
67
+ def due
68
+ all.due
69
+ end
70
+
71
+ # Calls +perform_now+ on each job.
72
+ #
73
+ # @param batch_size [Integer, NilClass] the number of jobs to fetch at a time, or +nil+ to fetch all jobs at once
74
+ # @return [Array] the results returned by each job
75
+ def perform_all(batch_size: nil)
76
+ all.perform_all(batch_size: batch_size)
77
+ end
78
+
79
+ # Discards all jobs.
80
+ #
81
+ # @return [Numeric] the number of discarded jobs
82
+ def discard_all
83
+ all.discard_all
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job'
4
+ require 'active_record'
5
+ require_relative 'record_interface'
6
+
7
+ module Marj
8
+ # The Marj ActiveRecord model class.
9
+ #
10
+ # See https://github.com/nicholasdower/marj
11
+ class Record < ActiveRecord::Base
12
+ include Marj::RecordInterface
13
+ extend Marj::RecordInterface::ClassMethods # Added explicitly to generate docs
14
+
15
+ self.table_name = 'jobs'
16
+ end
17
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job/arguments'
4
+
5
+ module Marj
6
+ # Provides base functionality for {Marj::Record}. Can be used to create a custom +ActiveRecord+ model class.
7
+ #
8
+ # Example Usage:
9
+ # class MyRecord < ActiveRecord::Base
10
+ # include Marj::RecordInterface
11
+ #
12
+ # self.table_name = 'my_jobs'
13
+ # end
14
+ module RecordInterface
15
+ # Adds {ClassMethods}, custom serializers and an implicit order column to the including class.
16
+ #
17
+ # @param clazz [Class] the including class
18
+ def self.included(clazz)
19
+ clazz.extend(ClassMethods)
20
+
21
+ # Order by +enqueued_at+ rather than +job_id+ (the default).
22
+ clazz.implicit_order_column = 'enqueued_at'
23
+
24
+ # Using a custom serializer for exception_executions so that we can interact with it as a hash rather than a
25
+ # string.
26
+ clazz.serialize(:exception_executions, coder: JSON)
27
+
28
+ # Using a custom serializer for arguments so that we can interact with as an array rather than a string.
29
+ # This enables code like:
30
+ # Marj::Record.next.arguments.first
31
+ # Marj::Record.next.update!(arguments: ['foo', 1, Time.now])
32
+ clazz.serialize(:arguments, coder: Class.new do
33
+ def self.dump(arguments)
34
+ return ActiveJob::Arguments.serialize(arguments).to_json if arguments.is_a?(Array)
35
+ return arguments if arguments.is_a?(String) || arguments.nil?
36
+
37
+ raise "invalid arguments: #{arguments}"
38
+ end
39
+
40
+ def self.load(arguments)
41
+ arguments ? ActiveJob::Arguments.deserialize(JSON.parse(arguments)) : nil
42
+ end
43
+ end)
44
+
45
+ # Using a custom serializer for job_class so that we can interact with it as a class rather than a string.
46
+ clazz.serialize(:job_class, coder: Class.new do
47
+ def self.dump(clazz)
48
+ return clazz.name if clazz.is_a?(Class)
49
+ return clazz if clazz.is_a?(String) || clazz.nil?
50
+
51
+ raise "invalid class: #{clazz}"
52
+ end
53
+
54
+ def self.load(str)
55
+ str&.constantize
56
+ end
57
+ end)
58
+ end
59
+
60
+ # Class methods for {Marj::RecordInterface}.
61
+ module ClassMethods
62
+ # Returns an +ActiveRecord::Relation+ scope for enqueued jobs with a +scheduled_at+ that is either +null+ or in
63
+ # the past.
64
+ #
65
+ # @return [ActiveRecord::Relation]
66
+ def due
67
+ where('scheduled_at IS NULL OR scheduled_at <= ?', Time.now.utc)
68
+ end
69
+
70
+ # Returns an +ActiveRecord::Relation+ scope for jobs ordered by +priority+ (+null+ last), then +scheduled_at+
71
+ # (+null+ last), then +enqueued_at+.
72
+ #
73
+ # @return [ActiveRecord::Relation]
74
+ def ordered
75
+ order(
76
+ Arel.sql(<<~SQL.squish, Time.now.utc)
77
+ CASE WHEN scheduled_at IS NULL OR scheduled_at <= ? THEN 0 ELSE 1 END,
78
+ CASE WHEN priority IS NULL THEN 1 ELSE 0 END, priority,
79
+ CASE WHEN scheduled_at IS NULL THEN 1 ELSE 0 END, scheduled_at,
80
+ enqueued_at
81
+ SQL
82
+ )
83
+ end
84
+ end
85
+
86
+ # Returns a job object for this record which will update the database when successfully executed, enqueued or
87
+ # discarded.
88
+ #
89
+ # @return [ActiveJob::Base]
90
+ def as_job
91
+ Marj.send(:to_job, self)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,112 @@
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
+ # Returns a Marj::Relation which wraps the specified +ActiveRecord+ relation.
12
+ def initialize(ar_relation)
13
+ @ar_relation = ar_relation
14
+ end
15
+
16
+ # (see Marj::JobsInterface#queue)
17
+ def queue(queue, *queues)
18
+ Marj::Relation.new(@ar_relation.where(queue_name: queues.dup.unshift(queue)))
19
+ end
20
+
21
+ # (see Marj::JobsInterface#next)
22
+ def next(limit = nil)
23
+ @ar_relation.first(limit)&.then { _1.is_a?(Array) ? _1.map(&:as_job) : _1.as_job }
24
+ end
25
+
26
+ # (see Marj::JobsInterface#count)
27
+ def count(column_name = nil, &block)
28
+ block_given? ? @ar_relation.count(column_name) { |r| block.call(r.as_job) } : @ar_relation.count(column_name)
29
+ end
30
+
31
+ # (see Marj::JobsInterface#where)
32
+ def where(*args)
33
+ Marj::Relation.new(@ar_relation.where(*args))
34
+ end
35
+
36
+ # (see Marj::JobsInterface#due)
37
+ def due
38
+ Marj::Relation.new(@ar_relation.due)
39
+ end
40
+
41
+ # (see Marj::JobsInterface#perform_all)
42
+ def perform_all(batch_size: nil)
43
+ if batch_size
44
+ [].tap do |results|
45
+ while (jobs = @ar_relation.limit(batch_size).map(&:as_job)).any?
46
+ results.concat(jobs.map { |job| ActiveJob::Callbacks.run_callbacks(:execute) { job.perform_now } })
47
+ end
48
+ end
49
+ else
50
+ @ar_relation.map(&:as_job).map { |job| ActiveJob::Callbacks.run_callbacks(:execute) { job.perform_now } }
51
+ end
52
+ end
53
+
54
+ # (see Marj::JobsInterface#discard_all)
55
+ def discard_all
56
+ @ar_relation.delete_all
57
+ end
58
+
59
+ # Yields each job in this relation.
60
+ #
61
+ # @param block [Proc]
62
+ # @return [Array] the jobs in this relation
63
+ def each(&block)
64
+ @ar_relation.map(&:as_job).each(&block)
65
+ end
66
+
67
+ # Provides +pretty_inspect+ output containing arrays of jobs rather than arrays of records, similar to the output
68
+ # produced when calling +pretty_inspect+ on +ActiveRecord::Relation+.
69
+ #
70
+ # Instead of the default +pretty_inspect+ output:
71
+ # > Marj::Jobs.all
72
+ # =>
73
+ # #<Marj::Relation:0x000000012728bd88
74
+ # @ar_relation=
75
+ # [#<Marj::Record:0x0000000126c42080
76
+ # job_id: "1382cb98-c518-46ca-a0cc-d831e11a0714",
77
+ # job_class: TestJob,
78
+ # arguments: ["foo"],
79
+ # queue_name: "default",
80
+ # priority: nil,
81
+ # executions: 0,
82
+ # exception_executions: {},
83
+ # enqueued_at: 2024-01-25 15:31:06.115773 UTC,
84
+ # scheduled_at: nil,
85
+ # locale: "en",
86
+ # timezone: "UTC">]>
87
+ #
88
+ # Produces:
89
+ # > Marj::Jobs.all
90
+ # =>
91
+ # [#<TestJob:0x000000010b63cef8
92
+ # @_scheduled_at_time=nil,
93
+ # @arguments=[],
94
+ # @enqueued_at=2024-01-25 15:31:06 UTC,
95
+ # @exception_executions={},
96
+ # @executions=0,
97
+ # @job_id="1382cb98-c518-46ca-a0cc-d831e11a0714",
98
+ # @locale="en",
99
+ # @priority=nil,
100
+ # @provider_job_id=nil,
101
+ # @queue_name="default",
102
+ # @scheduled_at=nil,
103
+ # @serialized_arguments=["foo"],
104
+ # @timezone="UTC">]
105
+ #
106
+ # @param pp [PP]
107
+ # @return [NilClass]
108
+ def pretty_print(pp)
109
+ pp.pp(to_a)
110
+ end
111
+ end
112
+ end
data/lib/marj.rb CHANGED
@@ -1,8 +1,106 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'marj_adapter'
4
+ require_relative 'marj/jobs'
5
+ require_relative 'marj/jobs_interface'
6
+ require_relative 'marj/record_interface'
7
+ require_relative 'marj/relation'
8
+
9
+ # A minimal database-backed ActiveJob queueing backend.
10
+ #
3
11
  # See https://github.com/nicholasdower/marj
12
+ module Marj
13
+ # The Marj version.
14
+ VERSION = '3.0.0'
4
15
 
5
- require_relative 'marj_adapter'
6
- require_relative 'marj_config'
16
+ Kernel.autoload(:Record, File.expand_path(File.join('marj', 'record.rb'), __dir__))
17
+
18
+ class << self
19
+ private
20
+
21
+ # Creates a job instance for the given record which will update the database when successfully executed, enqueued or
22
+ # discarded.
23
+ #
24
+ # @param record [ActiveRecord::Base]
25
+ # @return [ActiveJob::Base] the new job instance
26
+ def to_job(record)
27
+ # See register_callbacks for details on how callbacks are used.
28
+ job = record.job_class.new.tap { register_callbacks(_1, record) }
29
+
30
+ # ActiveJob::Base#deserialize expects serialized arguments. But the record arguments have already been
31
+ # deserialized by a custom ActiveRecord serializer (see below). So instead we use the raw arguments string.
32
+ job_data = record.attributes.merge('arguments' => JSON.parse(record.read_attribute_before_type_cast(:arguments)))
33
+
34
+ # ActiveJob::Base#deserialize expects dates to be strings rather than Time objects.
35
+ job_data = job_data.to_h { |k, v| [k, %w[enqueued_at scheduled_at].include?(k) ? v&.iso8601 : v] }
36
+
37
+ job.tap { job.deserialize(job_data) }
38
+ end
39
+ private :to_job
40
+
41
+ # Registers callbacks for the given job which destroy the given database record when the job succeeds or is
42
+ # discarded.
43
+ #
44
+ # @param job [ActiveJob::Base]
45
+ # @param record [ActiveRecord::Base]
46
+ # @return [ActiveJob::Base]
47
+ def register_callbacks(job, record)
48
+ raise 'callbacks already registered' if job.singleton_class.instance_variable_get(:@record)
49
+
50
+ # We need to detect three cases:
51
+ # - If a job succeeds, after_perform will be called.
52
+ # - If a job fails and should be retried, enqueue will be called. This is handled by the enqueue method.
53
+ # - If a job exceeds its max attempts, after_discard will be called.
54
+ job.singleton_class.after_perform { |_j| record.destroy! }
55
+ job.singleton_class.after_discard { |_j, _exception| record.destroy! }
56
+ job.singleton_class.instance_variable_set(:@record, record)
57
+
58
+ job
59
+ end
60
+ private :register_callbacks
61
+
62
+ # Enqueue a job for execution at the specified time.
63
+ #
64
+ # @param job [ActiveJob::Base] the job to enqueue
65
+ # @param record_class [Class] the +ActiveRecord+ model class
66
+ # @param time [Time, NilClass] optional time at which to execute the job
67
+ # @return [ActiveJob::Base] the enqueued job
68
+ def enqueue(job, record_class, time = nil)
69
+ job.scheduled_at = time
70
+ # Argument serialization is done by ActiveJob. ActiveRecord expects deserialized arguments.
71
+ serialized = job.serialize.symbolize_keys!.without(:provider_job_id).merge(arguments: job.arguments)
7
72
 
8
- Kernel.autoload(:Marj, File.expand_path('marj_record.rb', __dir__))
73
+ # When a job is enqueued, we must create/update the corresponding database record. We also must ensure callbacks
74
+ # are registered on the job instance so that when the job is executed, the database record is deleted or updated
75
+ # (depending on the result).
76
+ #
77
+ # We keep track of whether callbacks have been registered by setting the @record instance variable on the job's
78
+ # singleton class. This holds a reference to the record. This ensures that if execute is called on a record
79
+ # instance, any updates to the database are reflected on that record instance.
80
+ if (existing_record = job.singleton_class.instance_variable_get(:@record))
81
+ # This job instance has already been associated with a database row.
82
+ if record_class.exists?(job_id: job.job_id)
83
+ # The database row still exists, we simply need to update it.
84
+ existing_record.update!(serialized)
85
+ else
86
+ # Someone else deleted the database row, we need to recreate and reload the existing record instance. We don't
87
+ # want to register the new instance because someone might still have a reference to the existing one.
88
+ record_class.create!(serialized)
89
+ existing_record.reload
90
+ end
91
+ else
92
+ # This job instance has not been associated with a database row.
93
+ if (new_record = record_class.find_by(job_id: job.job_id))
94
+ # The database row already exists. Update it.
95
+ new_record.update!(serialized)
96
+ else
97
+ # The database row does not exist. Create it.
98
+ new_record = record_class.create!(serialized)
99
+ end
100
+ register_callbacks(job, new_record)
101
+ end
102
+ job
103
+ end
104
+ private :enqueue
105
+ end
106
+ 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+ model class.
8
+ #
9
+ # @param record_class [Class, String] the +ActiveRecord+ model 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: 3.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-27 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,19 @@ files:
48
48
  - LICENSE.txt
49
49
  - README.md
50
50
  - lib/marj.rb
51
+ - lib/marj/jobs.rb
52
+ - lib/marj/jobs_interface.rb
53
+ - lib/marj/record.rb
54
+ - lib/marj/record_interface.rb
55
+ - lib/marj/relation.rb
51
56
  - lib/marj_adapter.rb
52
- - lib/marj_config.rb
53
- - lib/marj_record.rb
54
57
  homepage: https://github.com/nicholasdower/marj
55
58
  licenses:
56
59
  - MIT
57
60
  metadata:
58
61
  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
62
+ changelog_uri: https://github.com/nicholasdower/marj/releases/tag/v3.0.0
63
+ documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/v3.0.0
61
64
  homepage_uri: https://github.com/nicholasdower/marj
62
65
  rubygems_mfa_required: 'true'
63
66
  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