marj 4.1.0 → 5.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +128 -121
- data/lib/marj/record.rb +49 -6
- data/lib/marj.rb +63 -133
- 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,136 +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 in order to, for instance,
|
164
|
-
write jobs to multiple databases/tables within a single application.
|
143
|
+
## Example Usage
|
165
144
|
|
166
145
|
```ruby
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
table.string :job_class, null: false
|
171
|
-
table.text :arguments, null: false
|
172
|
-
table.string :queue_name, null: false
|
173
|
-
table.integer :priority
|
174
|
-
table.integer :executions, null: false
|
175
|
-
table.text :exception_executions, null: false
|
176
|
-
table.datetime :enqueued_at, null: false
|
177
|
-
table.datetime :scheduled_at
|
178
|
-
table.string :locale, null: false
|
179
|
-
table.string :timezone, null: false
|
180
|
-
end
|
146
|
+
# Enqueue and manually run a job
|
147
|
+
job = SomeJob.perform_later('foo')
|
148
|
+
job.perform_now
|
181
149
|
|
182
|
-
|
183
|
-
|
184
|
-
add_index :my_jobs, %i[priority scheduled_at enqueued_at]
|
185
|
-
end
|
150
|
+
# Retrieve and execute a job
|
151
|
+
Marj.query(:due, :first).perform_now
|
186
152
|
|
187
|
-
|
188
|
-
|
189
|
-
end
|
190
|
-
end
|
153
|
+
# Run all due jobs (single DB query)
|
154
|
+
Marj.query(:due).map(&:perform_now)
|
191
155
|
|
192
|
-
|
193
|
-
|
156
|
+
# Run all due jobs (multiple DB queries)
|
157
|
+
loop do
|
158
|
+
break unless Marj.query(:due, :first)&.tap(&:perform_now)
|
194
159
|
end
|
195
160
|
|
196
|
-
|
197
|
-
|
198
|
-
class MyJob < ActiveJob::Base
|
199
|
-
self.queue_adapter = MarjAdapter.new('MyRecord')
|
200
|
-
|
201
|
-
extend Marj::JobsInterface
|
161
|
+
# Run all jobs in a specific queue which are due to be executed
|
162
|
+
Marj.query(:due, queue: :foo).map(&:perform_now)
|
202
163
|
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
puts msg
|
209
|
-
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
|
210
169
|
end
|
211
|
-
|
212
|
-
MyJob.perform_later('oh, hi')
|
213
|
-
MyJob.due.next.perform_now
|
214
170
|
```
|
215
171
|
|
216
172
|
## Testing
|
@@ -290,6 +246,57 @@ class ApplicationJob < ActiveJob::Base
|
|
290
246
|
end
|
291
247
|
```
|
292
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
|
+
|
293
300
|
## ActiveJob Cheatsheet
|
294
301
|
|
295
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,164 +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
|
-
|
50
|
-
|
51
|
-
|
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
|
48
|
+
#
|
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
|
59
52
|
#
|
60
|
-
#
|
61
|
-
|
62
|
-
|
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.deserialize(job_data)
|
91
|
-
|
92
|
-
# ActiveJob deserializes arguments on demand when a job is performed. Until then they are empty. That's strange.
|
93
|
-
# Instead, deserialize them now. Also, clear `serialized_arguments` to prevent ActiveJob from overwriting changes
|
94
|
-
# to arguments when serializing later.
|
95
|
-
job.arguments = record.arguments
|
96
|
-
job.serialized_arguments = nil
|
97
|
-
|
98
|
-
job
|
64
|
+
queue_adapter.discard(job)
|
99
65
|
end
|
66
|
+
end
|
100
67
|
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
# @param record [ActiveRecord::Base]
|
106
|
-
# @return [ActiveJob::Base]
|
107
|
-
def register_callbacks(job, record)
|
108
|
-
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
|
109
72
|
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
job.singleton_class.after_perform { |_j| record.destroy! }
|
115
|
-
job.singleton_class.after_discard { |_j, _exception| record.destroy! }
|
116
|
-
job.singleton_class.instance_variable_set(:@record, record)
|
73
|
+
# (see ClassMethods#discard)
|
74
|
+
def self.discard(job)
|
75
|
+
queue_adapter.discard(job)
|
76
|
+
end
|
117
77
|
|
118
|
-
|
119
|
-
|
78
|
+
# Discards this job.
|
79
|
+
#
|
80
|
+
# @return [ActiveJob::Base] this job
|
81
|
+
def discard
|
82
|
+
self.class.queue_adapter.discard(self)
|
83
|
+
end
|
120
84
|
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
# @param time [Time, NilClass] optional time at which to execute the job
|
126
|
-
# @return [ActiveJob::Base] the enqueued job
|
127
|
-
def enqueue(job, record_class, time = nil)
|
128
|
-
job.scheduled_at = time
|
129
|
-
# Argument serialization is done by ActiveJob. ActiveRecord expects deserialized arguments.
|
130
|
-
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
|
131
89
|
|
132
|
-
|
133
|
-
|
134
|
-
# (depending on the result).
|
135
|
-
#
|
136
|
-
# We keep track of whether callbacks have been registered by setting the @record instance variable on the job's
|
137
|
-
# singleton class. This holds a reference to the record. This ensures that if execute is called on a record
|
138
|
-
# instance, any updates to the database are reflected on that record instance.
|
139
|
-
if (existing_record = job.singleton_class.instance_variable_get(:@record))
|
140
|
-
# This job instance has already been associated with a database row.
|
141
|
-
if record_class.exists?(job_id: job.job_id)
|
142
|
-
# The database row still exists, we simply need to update it.
|
143
|
-
existing_record.update!(serialized)
|
144
|
-
else
|
145
|
-
# Someone else deleted the database row, we need to recreate and reload the existing record instance. We don't
|
146
|
-
# want to register the new instance because someone might still have a reference to the existing one.
|
147
|
-
record_class.create!(serialized)
|
148
|
-
existing_record.reload
|
149
|
-
end
|
150
|
-
else
|
151
|
-
# This job instance has not been associated with a database row.
|
152
|
-
if (new_record = record_class.find_by(job_id: job.job_id))
|
153
|
-
# The database row already exists. Update it.
|
154
|
-
new_record.update!(serialized)
|
155
|
-
else
|
156
|
-
# The database row does not exist. Create it.
|
157
|
-
new_record = record_class.create!(serialized)
|
158
|
-
end
|
159
|
-
register_callbacks(job, new_record)
|
160
|
-
end
|
161
|
-
job
|
162
|
-
end
|
90
|
+
def self.queue_adapter
|
91
|
+
ActiveJob::Base.queue_adapter
|
163
92
|
end
|
93
|
+
private_class_method :queue_adapter
|
164
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
|