marj 4.0.0 → 5.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: 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