marj 2.1.0 → 3.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: 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