marj 4.0.0 → 5.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: 4eb6129fa4948ed6e108469f44f380cda908620c6d08392cfa1e8c3bc84a9875
4
- data.tar.gz: 28faed9a10fa3a8c885b521d6c3465196f814e97af07018d5eea88e580f322a9
3
+ metadata.gz: 450ae15f6cf892215323b4eeeccdbf8a75c2db6162b0a75378c411f6e0bdac9c
4
+ data.tar.gz: 5b1fabd1000b8439e158aabc943deef507045252efe6805cd26a89629e39efe0
5
5
  SHA512:
6
- metadata.gz: 5f984f366a9cefb3187cbac7129144c8241d46d00357d719ec442dfce62d5c833c95ec30363a5af867e111499cc91cd2c60768da55d04c9c7fc401cce8ac3f83
7
- data.tar.gz: e86029649f11ca05f96efcae59c1c60212b29335d9b154c3288a15aa81cd432b46f960166680b5bb0a534346852ccc73970cbaf8a298eb52893c37e260854f61
6
+ metadata.gz: 1e49dcbc43034f454fb57bf1dca72972eba67719cd8c9aa1aa43b10f91287d2bab41bb97025dbca688de9e6630f0c0d494e6607100382165f56a0f93a38fc5e4
7
+ data.tar.gz: d075bd8f29864ea83be5ce0099f925f9bb527f9373940427c98cfce3545ee25a565a1fb6293023938bbd39b320ab55015b924281a31bdabb43085398551342cc
data/README.md CHANGED
@@ -2,9 +2,14 @@
2
2
 
3
3
  A minimal database-backed ActiveJob queueing backend.
4
4
 
5
+ ## Purpose
6
+
7
+ To provide a database-backed ActiveJob queueing backend with as few features
8
+ as possible and the minimum backend-specific API required.
9
+
5
10
  ## Quick Links
6
11
 
7
- API docs: https://gemdocs.org/gems/marj/latest <br>
12
+ API docs: https://gemdocs.org/gems/marj/5.0.0/ <br>
8
13
  RubyGems: https://rubygems.org/gems/marj <br>
9
14
  Changelog: https://github.com/nicholasdower/marj/releases <br>
10
15
  Issues: https://github.com/nicholasdower/marj/issues <br>
@@ -12,34 +17,57 @@ Development: https://github.com/nicholasdower/marj/blob/master/CONTRIBUTING.md
12
17
 
13
18
  ## Features
14
19
 
20
+ ### Provided
21
+
15
22
  - Enqueued jobs are written to the database.
16
23
  - Successfully executed jobs are deleted from the database.
17
24
  - Failed jobs which should be retried are updated in the database.
18
25
  - 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.
26
+ - An method is provided to query enqueued jobs.
27
+ - An method is provided to discard enqueued jobs.
20
28
  - An `ActiveRecord` class is provided to query the database directly.
21
29
 
22
- ## Features Not Provided
30
+ ### Not Provided
23
31
 
24
- - Workers
32
+ - Automatic job execution
25
33
  - Timeouts
26
- - Concurrency Controls
34
+ - Concurrency controls
27
35
  - Observability
28
- - A User Interace
36
+ - A user interace
37
+
38
+ Note that because Marj does not automatically execute jobs, clients are
39
+ responsible for retrieving and either executing or discarding jobs.
40
+
41
+ ## API
42
+
43
+ The ActiveJob API already provides methods for enqueueing and performing jobs:
44
+
45
+ ```ruby
46
+ queue_adapter.enqueue(job) # Enqueue
47
+ job.enqueue # Enqueue
48
+ job.perform_now # Perform
49
+ ```
50
+
51
+ Marj works with these existing methods and additionally extends the ActiveJob API
52
+ with methods for querying and discarding jobs:
53
+
54
+ ```ruby
55
+ queue_adapter.query(args) # Query
56
+ SomeJob.query(args) # Query
57
+ queue_adapter.discard(job) # Discard
58
+ job.discard # Discard
59
+ ```
29
60
 
30
61
  ## Setup
31
62
 
32
63
  ### 1. Install
33
64
 
34
65
  ```shell
35
- bundle add activejob activerecord marj
36
-
37
- # or
38
-
39
- gem install activejob activerecord marj
66
+ bundle add activejob activerecord marj # via Bundler
67
+ gem install activejob activerecord marj # or globally
40
68
  ```
41
69
 
42
- ### 3. Create the database table
70
+ ### 2. Create the database table
43
71
 
44
72
  ```ruby
45
73
  class CreateJobs < ActiveRecord::Migration[7.1]
@@ -68,10 +96,7 @@ class CreateJobs < ActiveRecord::Migration[7.1]
68
96
  end
69
97
  ```
70
98
 
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
-
74
- ### 4. Configure the queue adapter
99
+ ### 3. Configure the queue adapter
75
100
 
76
101
  ```ruby
77
102
  require 'marj'
@@ -81,137 +106,67 @@ ActiveJob::Base.queue_adapter = :marj # Globally, without Rails
81
106
  SomeJob.queue_adapter = :marj # Single job
82
107
  ```
83
108
 
84
- ## Example Usage
85
-
86
- ```ruby
87
- # Enqueue and manually run a job:
88
- job = SomeJob.perform_later('foo')
89
- job.perform_now
90
-
91
- # Retrieve and execute a job
92
- Marj.due.next.perform_now
93
-
94
- # Run all due jobs (single DB query)
95
- Marj.due.perform_all
96
-
97
- # Run all due jobs (multiple DB queries)
98
- Marj.due.perform_all(batch_size: 1)
99
-
100
- # Run all due jobs in a specific queue:
101
- Marj.queue('foo').due.perform_all
102
-
103
- # Run jobs as they become due:
104
- loop do
105
- Marj.due.perform_all rescue logger.error($!)
106
- ensure
107
- sleep 5.seconds
108
- end
109
- ```
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`.
109
+ ### 4. Include the Marj module (optional)
117
110
 
118
- Example usage:
111
+ By default, jobs can be queried and discarded via the `MarjAdapter` or the
112
+ `Marj` module:
119
113
 
120
114
  ```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.
115
+ Marj.query(:all)
116
+ ActiveJob::Base.queue_adapter.query(:all)
117
+ Marj.discard(job)
118
+ ActiveJob::Base.queue_adapter.discard(job)
130
119
  ```
131
120
 
132
- Query methods can also be chained:
121
+ But it is also convenient to query or discard via job classes:
133
122
 
134
123
  ```ruby
135
- Marj.due.where(job_class: SomeJob).next # Returns the next SomeJob that is due
124
+ ApplicationJob.query(:all)
125
+ SomeJob.query(:all)
126
+ ApplicationJob.discard(job)
127
+ SomeJob.discard(job)
128
+ job.discard
136
129
  ```
137
130
 
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:
131
+ In order to enable this functionality, you must include the `Marj` module:
142
132
 
143
133
  ```ruby
144
134
  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
135
+ include Marj
153
136
  end
154
137
 
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.
138
+ class SomeJob < ApplicationJob
139
+ def perform; end
140
+ end
159
141
  ```
160
142
 
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.
143
+ ## Example Usage
166
144
 
167
145
  ```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
146
+ # Enqueue and manually run a job
147
+ job = SomeJob.perform_later('foo')
148
+ job.perform_now
182
149
 
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
150
+ # Retrieve and execute a job
151
+ Marj.query(:due, :first).perform_now
187
152
 
188
- def self.down
189
- drop_table :my_jobs
190
- end
191
- end
153
+ # Run all due jobs (single DB query)
154
+ Marj.query(:due).map(&:perform_now)
192
155
 
193
- class MyRecord < Marj::Record
194
- self.table_name = 'my_jobs'
156
+ # Run all due jobs (multiple DB queries)
157
+ loop do
158
+ break unless Marj.query(:due, :first)&.tap(&:perform_now)
195
159
  end
196
160
 
197
- CreateMyJobs.migrate(:up)
161
+ # Run all jobs in a specific queue which are due to be executed
162
+ Marj.query(:due, queue: :foo).map(&:perform_now)
198
163
 
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
164
+ # Run jobs as they become due
165
+ loop do
166
+ Marj.query(:due).each(&:perform_now) rescue logger.error($!)
167
+ ensure
168
+ sleep 5.seconds
211
169
  end
212
-
213
- MyJob.perform_later('oh, hi')
214
- MyJob.due.next.perform_now
215
170
  ```
216
171
 
217
172
  ## Testing
@@ -291,6 +246,57 @@ class ApplicationJob < ActiveJob::Base
291
246
  end
292
247
  ```
293
248
 
249
+ ### Multiple Tables/Databases
250
+
251
+ It is possible to create a custom record class in order to, for instance,
252
+ write jobs to multiple databases/tables within a single application.
253
+
254
+ ```ruby
255
+ class CreateMyJobs < ActiveRecord::Migration[7.1]
256
+ def self.up
257
+ create_table :my_jobs, id: :string, primary_key: :job_id do |table|
258
+ table.string :job_class, null: false
259
+ table.text :arguments, null: false
260
+ table.string :queue_name, null: false
261
+ table.integer :priority
262
+ table.integer :executions, null: false
263
+ table.text :exception_executions, null: false
264
+ table.datetime :enqueued_at, null: false
265
+ table.datetime :scheduled_at
266
+ table.string :locale, null: false
267
+ table.string :timezone, null: false
268
+ end
269
+
270
+ add_index :my_jobs, %i[enqueued_at]
271
+ add_index :my_jobs, %i[scheduled_at]
272
+ add_index :my_jobs, %i[priority scheduled_at enqueued_at]
273
+ end
274
+
275
+ def self.down
276
+ drop_table :my_jobs
277
+ end
278
+ end
279
+
280
+ class MyRecord < Marj::Record
281
+ self.table_name = 'my_jobs'
282
+ end
283
+
284
+ CreateMyJobs.migrate(:up)
285
+
286
+ class MyJob < ActiveJob::Base
287
+ self.queue_adapter = MarjAdapter.new('MyRecord')
288
+
289
+ include Marj
290
+
291
+ def perform(msg)
292
+ puts msg
293
+ end
294
+ end
295
+
296
+ MyJob.perform_later('oh, hi')
297
+ MyJob.query(:due, :first).perform_now
298
+ ```
299
+
294
300
  ## ActiveJob Cheatsheet
295
301
 
296
302
  For more information on ActiveJob, see:
data/lib/marj/record.rb CHANGED
@@ -6,7 +6,7 @@ require 'active_record'
6
6
  module Marj
7
7
  # The default +ActiveRecord+ class.
8
8
  class Record < ActiveRecord::Base
9
- self.table_name = Marj.table_name
9
+ self.table_name = :jobs
10
10
 
11
11
  # Order by +enqueued_at+ rather than +job_id+ (the default).
12
12
  self.implicit_order_column = 'enqueued_at'
@@ -50,9 +50,47 @@ module Marj
50
50
  # discarded.
51
51
  #
52
52
  # @return [ActiveJob::Base]
53
- def as_job
54
- Marj.send(:to_job, self)
53
+ def to_job
54
+ # See register_callbacks for details on how callbacks are used.
55
+ job = job_class.new.tap { register_callbacks(_1) }
56
+
57
+ # ActiveJob::Base#deserialize expects serialized arguments. But the record arguments have already been
58
+ # deserialized by a custom ActiveRecord serializer (see below). So instead we use the raw arguments string.
59
+ job_data = attributes.merge('arguments' => JSON.parse(read_attribute_before_type_cast(:arguments)))
60
+
61
+ # ActiveJob::Base#deserialize expects dates to be strings rather than Time objects.
62
+ job_data = job_data.to_h { |k, v| [k, %w[enqueued_at scheduled_at].include?(k) ? v&.iso8601 : v] }
63
+
64
+ job.deserialize(job_data)
65
+
66
+ # ActiveJob deserializes arguments on demand when a job is performed. Until then they are empty. That's strange.
67
+ # Instead, deserialize them now. Also, clear `serialized_arguments` to prevent ActiveJob from overwriting changes
68
+ # to arguments when serializing later.
69
+ job.arguments = arguments
70
+ job.serialized_arguments = nil
71
+
72
+ job
73
+ end
74
+
75
+ # Registers callbacks for the given job which destroy this record when the job succeeds or is discarded.
76
+ #
77
+ # @param job [ActiveJob::Base]
78
+ # @return [ActiveJob::Base]
79
+ def register_callbacks(job)
80
+ raise 'callbacks already registered' if job.singleton_class.instance_variable_get(:@record)
81
+
82
+ record = self
83
+ # We need to detect three cases:
84
+ # - If a job succeeds, after_perform will be called.
85
+ # - If a job fails and should be retried, enqueue will be called. This is handled by the queue adapter.
86
+ # - If a job exceeds its max attempts, after_discard will be called.
87
+ job.singleton_class.after_perform { |_j| record.destroy! }
88
+ job.singleton_class.after_discard { |_j, _exception| record.destroy! }
89
+ job.singleton_class.instance_variable_set(:@record, record)
90
+
91
+ job
55
92
  end
93
+ private :register_callbacks
56
94
 
57
95
  class << self
58
96
  # Returns an +ActiveRecord::Relation+ scope for enqueued jobs with a +scheduled_at+ that is either +null+ or in
@@ -63,11 +101,16 @@ module Marj
63
101
  where('scheduled_at IS NULL OR scheduled_at <= ?', Time.now.utc)
64
102
  end
65
103
 
66
- # Returns an +ActiveRecord::Relation+ scope for jobs ordered by +priority+ (+null+ last), then +scheduled_at+
67
- # (+null+ last), then +enqueued_at+.
104
+ # Returns an +ActiveRecord::Relation+ scope for jobs ordered by due date.
105
+ #
106
+ # Jobs are ordered by the following criteria, in order:
107
+ # 1. past or null scheduled_at before future scheduled_at
108
+ # 2. ascending priority, nulls last
109
+ # 3. ascending scheduled_at, nulls last
110
+ # 4. ascending enqueued_at
68
111
  #
69
112
  # @return [ActiveRecord::Relation]
70
- def ordered
113
+ def by_due_date
71
114
  order(
72
115
  Arel.sql(<<~SQL.squish, Time.now.utc)
73
116
  CASE WHEN scheduled_at IS NULL OR scheduled_at <= ? THEN 0 ELSE 1 END,
data/lib/marj.rb CHANGED
@@ -1,156 +1,94 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'marj_adapter'
4
- require_relative 'marj/jobs_interface'
5
- require_relative 'marj/relation'
6
4
 
7
5
  # A minimal database-backed ActiveJob queueing backend.
8
6
  #
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}.
7
+ # The {Marj} module provides the following methods:
8
+ # - +query+ - Queries enqueued jobs
9
+ # - +discard+ - Discards a job
10
+ #
11
+ # It is possible to call the above methods on the {Marj} module itself or on any class which includes it.
12
12
  #
13
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.
14
+ # Marj.query(:first) # Returns the first job
15
+ # Marj.discard(job) # Discards the specified job
16
+ #
17
+ # class ApplicationJob < ActiveJob::Base
18
+ # include Marj
19
+ # end
23
20
  #
24
- # Query methods can also be chained:
25
- # Marj.due.where(job_class: SomeJob).next # Returns the next SomeJob that is due
21
+ # class SomeJob < ApplicationJob;
22
+ # def perform; end
23
+ # end
26
24
  #
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+.
25
+ # job = ApplicationJob.query(:first) # Returns the first enqueued job
26
+ # job = SomeJob.query(:first) # Returns the first enqueued job with job_class SomeJob
27
+ # ApplicationJob.discard(job) # Discards the specified job
28
+ # job.discard # Discards the job
29
29
  #
30
30
  # See https://github.com/nicholasdower/marj
31
31
  module Marj
32
32
  # The Marj version.
33
- VERSION = '4.0.0'
33
+ VERSION = '5.0.0'
34
34
 
35
35
  Kernel.autoload(:Record, File.expand_path(File.join('marj', 'record.rb'), __dir__))
36
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.
37
+ # Provides the {query} and {discard} class methods.
38
+ module ClassMethods
39
+ # Queries enqueued jobs.
40
+ #
41
+ # Similar to +ActiveRecord.where+ with a few additional features.
42
+ #
43
+ # Example usage:
44
+ # query(:all) # Delegates to Marj::Record.all
45
+ # query(:due) # Delegates to Marj::Record.due
46
+ # query(:all, limit: 10) # Returns a maximum of 10 jobs
47
+ # query(job_class: Foo) # Returns all jobs with job_class Foo
59
48
  #
60
- # @return [Marj::Relation]
61
- def all
62
- Marj::Relation.new(Marj.record_class.ordered)
49
+ # query('123') # Returns the job with id '123' or nil if no such job exists
50
+ # query(id: '123') # Same as above
51
+ # query(job_id: '123') # Same as above
52
+ #
53
+ # query(queue: 'foo') # Returns all jobs in the 'foo' queue
54
+ # query(job_queue: 'foo') # Same as above
55
+ def query(*args, **kwargs)
56
+ kwargs[:job_class] ||= self if self < ActiveJob::Base && name != 'ApplicationJob'
57
+ queue_adapter.query(*args, **kwargs)
63
58
  end
64
59
 
65
60
  # Discards the specified job.
66
61
  #
67
- # @return [Integer] the number of discarded jobs
62
+ # @return [ActiveJob::Base] the discarded job
68
63
  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) }
64
+ queue_adapter.discard(job)
91
65
  end
66
+ end
92
67
 
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)
68
+ # (see ClassMethods#query)
69
+ def self.query(*args, **kwargs)
70
+ queue_adapter.query(*args, **kwargs)
71
+ end
101
72
 
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)
73
+ # (see ClassMethods#discard)
74
+ def self.discard(job)
75
+ queue_adapter.discard(job)
76
+ end
109
77
 
110
- job
111
- end
78
+ # Discards this job.
79
+ #
80
+ # @return [ActiveJob::Base] this job
81
+ def discard
82
+ self.class.queue_adapter.discard(self)
83
+ end
112
84
 
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)
85
+ def self.included(clazz)
86
+ clazz.extend(ClassMethods)
87
+ end
88
+ private_class_method :included
123
89
 
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
90
+ def self.queue_adapter
91
+ ActiveJob::Base.queue_adapter
155
92
  end
93
+ private_class_method :queue_adapter
156
94
  end
data/lib/marj_adapter.rb CHANGED
@@ -2,8 +2,17 @@
2
2
 
3
3
  # ActiveJob queue adapter for Marj.
4
4
  #
5
+ # In addition to the standard +ActiveJob+ queue adapter API, this adapter provides a +query+ method which can be used to
6
+ # query enqueued jobs and a +discard+ method which can be used to discard enqueued jobs.
7
+ #
8
+ # Although it is possible to access the adapter directly in order to query or discard, it is recommended to use the
9
+ # {Marj} module.
10
+ #
5
11
  # See https://github.com/nicholasdower/marj
6
12
  class MarjAdapter
13
+ JOB_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.freeze
14
+ private_constant :JOB_ID_REGEX
15
+
7
16
  # Creates a new adapter which will enqueue jobs using the given +ActiveRecord+ class.
8
17
  #
9
18
  # @param record_class [Class, String] the +ActiveRecord+ class (or its name) to use to store jobs
@@ -16,7 +25,7 @@ class MarjAdapter
16
25
  # @param job [ActiveJob::Base] the job to enqueue
17
26
  # @return [ActiveJob::Base] the enqueued job
18
27
  def enqueue(job)
19
- Marj.send(:enqueue, job, record_class)
28
+ enqueue_at(job)
20
29
  end
21
30
 
22
31
  # Enqueue a job for execution at the specified time.
@@ -24,8 +33,115 @@ class MarjAdapter
24
33
  # @param job [ActiveJob::Base] the job to enqueue
25
34
  # @param timestamp [Numeric, NilClass] optional number of seconds since Unix epoch at which to execute the job
26
35
  # @return [ActiveJob::Base] the enqueued job
27
- def enqueue_at(job, timestamp)
28
- Marj.send(:enqueue, job, record_class, timestamp ? Time.at(timestamp).utc : nil)
36
+ def enqueue_at(job, timestamp = nil)
37
+ job.scheduled_at = timestamp ? Time.at(timestamp).utc : nil
38
+
39
+ # Argument serialization is done by ActiveJob. ActiveRecord expects deserialized arguments.
40
+ serialized = job.serialize.symbolize_keys!.without(:provider_job_id).merge(arguments: job.arguments)
41
+
42
+ # Serialize sets locale to I18n.locale.to_s and enqueued_at to Time.now.utc.iso8601(9).
43
+ # Update the job to reflect what is being enqueued.
44
+ job.locale = serialized[:locale]
45
+ job.enqueued_at = Time.iso8601(serialized[:enqueued_at]).utc
46
+
47
+ # When a job is enqueued, we must create/update the corresponding database record. We also must ensure callbacks
48
+ # are registered on the job instance so that when the job is executed, the database record is deleted or updated
49
+ # (depending on the result).
50
+ #
51
+ # We keep track of whether callbacks have been registered by setting the @record instance variable on the job's
52
+ # singleton class. This holds a reference to the record. This ensures that if execute is called on a record
53
+ # instance, any updates to the database are reflected on that record instance.
54
+ if (existing_record = job.singleton_class.instance_variable_get(:@record))
55
+ # This job instance has already been associated with a database row.
56
+ if record_class.exists?(job_id: job.job_id)
57
+ # The database row still exists, we simply need to update it.
58
+ existing_record.update!(serialized)
59
+ else
60
+ # Someone else deleted the database row, we need to recreate and reload the existing record instance. We don't
61
+ # want to register the new instance because someone might still have a reference to the existing one.
62
+ record_class.create!(serialized)
63
+ existing_record.reload
64
+ end
65
+ else
66
+ # This job instance has not been associated with a database row.
67
+ if (new_record = record_class.find_by(job_id: job.job_id))
68
+ # The database row already exists. Update it.
69
+ new_record.update!(serialized)
70
+ else
71
+ # The database row does not exist. Create it.
72
+ new_record = record_class.create!(serialized)
73
+ end
74
+ new_record.send(:register_callbacks, job)
75
+ end
76
+ job
77
+ end
78
+
79
+ # Queries enqueued jobs. Similar to +ActiveRecord.where+ with a few additional features:
80
+ # - Leading symbol arguments are treated as +ActiveRecord+ scopes.
81
+ # - If only a job ID is specified, the corresponding job is returned.
82
+ # - If +:limit+ is specified, the maximum number of jobs is limited.
83
+ #
84
+ # Example usage:
85
+ # query(:all) # Delegates to Marj::Record.all
86
+ # query(:due) # Delegates to Marj::Record.due
87
+ # query(:all, limit: 10) # Returns a maximum of 10 jobs
88
+ # query(job_class: Foo) # Returns all jobs with job_class Foo
89
+ #
90
+ # query('123') # Returns the job with id '123' or nil if no such job exists
91
+ # query(id: '123') # Same as above
92
+ # query(job_id: '123') # Same as above
93
+ #
94
+ # query(queue: 'foo') # Returns all jobs in the 'foo' queue
95
+ # query(job_queue: 'foo') # Same as above
96
+ def query(*args, **kwargs)
97
+ args, kwargs = args.dup, kwargs.dup.symbolize_keys
98
+ kwargs = kwargs.merge(queue_name: kwargs.delete(:queue)) if kwargs.key?(:queue)
99
+ kwargs = kwargs.merge(job_id: kwargs.delete(:id)) if kwargs.key?(:id)
100
+ kwargs[:job_id] = args.shift if args.size == 1 && args.first.is_a?(String) && args.first.match(JOB_ID_REGEX)
101
+
102
+ if args.empty? && kwargs.size == 1 && kwargs.key?(:job_id)
103
+ return record_class.find_by(job_id: kwargs[:job_id])&.to_job
104
+ end
105
+
106
+ symbol_args = []
107
+ symbol_args << args.shift while args.first.is_a?(Symbol)
108
+ order_by = kwargs.delete(:order)
109
+ order_by = :queue_name if [:queue, 'queue'].include?(order_by)
110
+ limit = kwargs.delete(:limit)
111
+ symbol_args.shift if symbol_args.first == :all
112
+ relation = record_class.all
113
+ relation = relation.order(order_by) if order_by
114
+ relation = relation.by_due_date unless relation.order_values.any?
115
+ relation = relation.where(*args, **kwargs) if args.any? || kwargs.any?
116
+ relation = relation.limit(limit) if limit
117
+ relation = relation.send(symbol_args.shift) while symbol_args.any?
118
+ if relation.is_a?(Enumerable)
119
+ relation.map(&:to_job)
120
+ elsif relation.is_a?(record_class)
121
+ relation.to_job
122
+ else
123
+ relation
124
+ end
125
+ end
126
+
127
+ # Discards the specified job.
128
+ #
129
+ # @return [ActiveJob::Base] the discarded job
130
+ def discard(job)
131
+ record = job.singleton_class.instance_variable_get(:@record)
132
+ record ||= Marj::Record.find_by(job_id: job.job_id)&.tap { _1.send(:register_callbacks, job) }
133
+ record&.destroy
134
+
135
+ # Copied from ActiveJob::Exceptions#run_after_discard_procs
136
+ exceptions = []
137
+ job.after_discard_procs.each do |blk|
138
+ instance_exec(job, nil, &blk)
139
+ rescue StandardError => e
140
+ exceptions << e
141
+ end
142
+ raise exceptions.last if exceptions.any?
143
+
144
+ job
29
145
  end
30
146
 
31
147
  private
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: 4.0.0
4
+ version: 5.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-28 00:00:00.000000000 Z
11
+ date: 2024-01-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -48,17 +48,15 @@ files:
48
48
  - LICENSE.txt
49
49
  - README.md
50
50
  - lib/marj.rb
51
- - lib/marj/jobs_interface.rb
52
51
  - lib/marj/record.rb
53
- - lib/marj/relation.rb
54
52
  - lib/marj_adapter.rb
55
53
  homepage: https://github.com/nicholasdower/marj
56
54
  licenses:
57
55
  - MIT
58
56
  metadata:
59
57
  bug_tracker_uri: https://github.com/nicholasdower/marj/issues
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
58
+ changelog_uri: https://github.com/nicholasdower/marj/releases/tag/v5.0.0
59
+ documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/v5.0.0
62
60
  homepage_uri: https://github.com/nicholasdower/marj
63
61
  rubygems_mfa_required: 'true'
64
62
  source_code_uri: https://github.com/nicholasdower/marj
@@ -1,113 +0,0 @@
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
data/lib/marj/relation.rb DELETED
@@ -1,72 +0,0 @@
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