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 +4 -4
- data/README.md +128 -122
- data/lib/marj/record.rb +49 -6
- data/lib/marj.rb +63 -125
- data/lib/marj_adapter.rb +119 -3
- metadata +4 -6
- data/lib/marj/jobs_interface.rb +0 -113
- data/lib/marj/relation.rb +0 -72
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 450ae15f6cf892215323b4eeeccdbf8a75c2db6162b0a75378c411f6e0bdac9c
|
4
|
+
data.tar.gz: 5b1fabd1000b8439e158aabc943deef507045252efe6805cd26a89629e39efe0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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/
|
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
|
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
|
-
|
30
|
+
### Not Provided
|
23
31
|
|
24
|
-
-
|
32
|
+
- Automatic job execution
|
25
33
|
- Timeouts
|
26
|
-
- Concurrency
|
34
|
+
- Concurrency controls
|
27
35
|
- Observability
|
28
|
-
- A
|
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
|
-
###
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
122
|
-
|
123
|
-
Marj.
|
124
|
-
|
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
|
-
|
121
|
+
But it is also convenient to query or discard via job classes:
|
133
122
|
|
134
123
|
```ruby
|
135
|
-
|
124
|
+
ApplicationJob.query(:all)
|
125
|
+
SomeJob.query(:all)
|
126
|
+
ApplicationJob.discard(job)
|
127
|
+
SomeJob.discard(job)
|
128
|
+
job.discard
|
136
129
|
```
|
137
130
|
|
138
|
-
|
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
|
-
|
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
|
156
|
-
|
157
|
-
|
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
|
-
##
|
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
|
-
|
169
|
-
|
170
|
-
|
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
|
-
|
184
|
-
|
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
|
-
|
189
|
-
|
190
|
-
end
|
191
|
-
end
|
153
|
+
# Run all due jobs (single DB query)
|
154
|
+
Marj.query(:due).map(&:perform_now)
|
192
155
|
|
193
|
-
|
194
|
-
|
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
|
-
|
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
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
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 =
|
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
|
54
|
-
|
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
|
67
|
-
#
|
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
|
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
|
10
|
-
# +
|
11
|
-
#
|
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.
|
15
|
-
# Marj.
|
16
|
-
#
|
17
|
-
#
|
18
|
-
#
|
19
|
-
#
|
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
|
-
#
|
25
|
-
#
|
21
|
+
# class SomeJob < ApplicationJob;
|
22
|
+
# def perform; end
|
23
|
+
# end
|
26
24
|
#
|
27
|
-
#
|
28
|
-
#
|
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 = '
|
33
|
+
VERSION = '5.0.0'
|
34
34
|
|
35
35
|
Kernel.autoload(:Record, File.expand_path(File.join('marj', 'record.rb'), __dir__))
|
36
36
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
#
|
44
|
-
#
|
45
|
-
#
|
46
|
-
|
47
|
-
|
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
|
-
#
|
61
|
-
|
62
|
-
|
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 [
|
62
|
+
# @return [ActiveJob::Base] the discarded job
|
68
63
|
def discard(job)
|
69
|
-
|
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
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
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
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
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
|
-
|
111
|
-
|
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
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
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
|
-
|
125
|
-
|
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
|
-
|
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
|
-
|
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
|
+
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-
|
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/
|
61
|
-
documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/
|
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
|
data/lib/marj/jobs_interface.rb
DELETED
@@ -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
|