marj 1.0.0 → 2.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 +95 -28
- data/app/models/marj.rb +82 -45
- data/lib/marj.rb +3 -21
- data/lib/marj_adapter.rb +21 -0
- data/lib/marj_config.rb +13 -0
- metadata +5 -4
- data/lib/marj/record.rb +0 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c4feb80230bafd2d67bdb0316598f4c46eabd01ca86c35fb8b01e04c865f91a8
|
4
|
+
data.tar.gz: be461234b8359c9f974dd4160d896cc048d0dcc1f4b7d74b57042d62f9950a18
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 31735c1c143a47ab490ed08eca12af6d8fce73b551637ed92e37bcb42e8b2560135ad674dfc832353988f4246c10f9ff2822f624bb10ba256070632dd35c209c
|
7
|
+
data.tar.gz: 7f19dee3c8b0510d27ca10eb8f9d64ecfc2e1174ae1cea71e17abf42f0ce8ef07be4f84e1eff9cf0150e6162bb3a790e066e36f17db131056e30ebbeada1bdc3
|
data/README.md
CHANGED
@@ -5,12 +5,14 @@ Marj is a Minimal ActiveRecord-based Jobs library.
|
|
5
5
|
API docs: https://www.rubydoc.info/github/nicholasdower/marj <br>
|
6
6
|
RubyGems: https://rubygems.org/gems/marj <br>
|
7
7
|
Changelog: https://github.com/nicholasdower/marj/releases <br>
|
8
|
-
Issues: https://github.com/nicholasdower/marj/issues
|
8
|
+
Issues: https://github.com/nicholasdower/marj/issues <br>
|
9
|
+
Development: https://github.com/nicholasdower/marj/blob/master/CONTRIBUTING.md
|
9
10
|
|
10
11
|
For more information on ActiveJob, see:
|
11
12
|
|
12
13
|
- https://edgeguides.rubyonrails.org/active_job_basics.html
|
13
14
|
- https://www.rubydoc.info/gems/activejob
|
15
|
+
- https://github.com/nicholasdower/marj/blob/master/README.md#activejob-cheatsheet
|
14
16
|
|
15
17
|
## Setup
|
16
18
|
|
@@ -22,9 +24,11 @@ Add the following to your Gemfile:
|
|
22
24
|
gem 'marj', '~> 1.0'
|
23
25
|
```
|
24
26
|
|
25
|
-
|
27
|
+
```shell
|
28
|
+
bundle install
|
29
|
+
```
|
26
30
|
|
27
|
-
|
31
|
+
### 2. Create the jobs table
|
28
32
|
|
29
33
|
```ruby
|
30
34
|
class CreateJobs < ActiveRecord::Migration[7.1]
|
@@ -53,34 +57,28 @@ class CreateJobs < ActiveRecord::Migration[7.1]
|
|
53
57
|
end
|
54
58
|
```
|
55
59
|
|
56
|
-
|
57
|
-
|
58
|
-
If using Rails, configure the queue adapter via `Rails::Application`:
|
60
|
+
To use a different table name:
|
59
61
|
|
60
62
|
```ruby
|
61
63
|
require 'marj'
|
62
64
|
|
63
|
-
|
64
|
-
class MyApplication < Rails::Application
|
65
|
-
config.active_job.queue_adapter = :marj
|
66
|
-
end
|
67
|
-
|
68
|
-
# Or for specific jobs:
|
69
|
-
class SomeJob < ActiveJob::Base
|
70
|
-
self.queue_adapter = :marj
|
71
|
-
end
|
65
|
+
MarjConfig.table_name = 'some_name'
|
72
66
|
```
|
73
67
|
|
74
|
-
|
68
|
+
### 3. Configure the queue adapter
|
75
69
|
|
76
70
|
```ruby
|
77
71
|
require 'marj'
|
78
|
-
require 'marj/record' # Loads ActiveRecord
|
79
72
|
|
80
|
-
#
|
73
|
+
# With rails:
|
74
|
+
class MyApplication < Rails::Application
|
75
|
+
config.active_job.queue_adapter = :marj
|
76
|
+
end
|
77
|
+
|
78
|
+
# Without Rails:
|
81
79
|
ActiveJob::Base.queue_adapter = :marj
|
82
80
|
|
83
|
-
# Or for specific jobs:
|
81
|
+
# Or for specific jobs (with or without Rails):
|
84
82
|
class SomeJob < ActiveJob::Base
|
85
83
|
self.queue_adapter = :marj
|
86
84
|
end
|
@@ -95,18 +93,87 @@ job.perform_now
|
|
95
93
|
|
96
94
|
# Enqueue, retrieve and manually run a job:
|
97
95
|
SomeJob.perform_later('foo')
|
98
|
-
Marj.first
|
96
|
+
record = Marj.first
|
97
|
+
record.execute
|
99
98
|
|
100
|
-
# Run all
|
101
|
-
Marj.
|
99
|
+
# Run all ready jobs:
|
100
|
+
while (record = Marj.ready.first)
|
101
|
+
record.execute
|
102
|
+
end
|
102
103
|
|
103
|
-
# Run jobs
|
104
|
+
# Run all ready jobs in a specific queue:
|
105
|
+
while (record = Marj.where(queue_name: 'foo').ready.first)
|
106
|
+
record.execute
|
107
|
+
end
|
108
|
+
|
109
|
+
# Run jobs as they become ready:
|
104
110
|
loop do
|
105
|
-
Marj.
|
111
|
+
while (record = Marj.ready.first)
|
112
|
+
record.execute
|
113
|
+
end
|
106
114
|
sleep 5.seconds
|
107
115
|
end
|
108
116
|
```
|
109
117
|
|
118
|
+
## Extension Examples
|
119
|
+
|
120
|
+
### Timeouts
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
class ApplicationJob < ActiveJob::Base
|
124
|
+
def self.timeout_after(duration)
|
125
|
+
@timeout = duration
|
126
|
+
end
|
127
|
+
|
128
|
+
around_perform do |job, block|
|
129
|
+
if (timeout = job.class.instance_variable_get(:@timeout))
|
130
|
+
::Timeout.timeout(timeout, StandardError, 'execution expired') { block.call }
|
131
|
+
else
|
132
|
+
block.call
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
```
|
137
|
+
|
138
|
+
### Last Error
|
139
|
+
|
140
|
+
```ruby
|
141
|
+
class AddLastErrorToJobs < ActiveRecord::Migration[7.1]
|
142
|
+
def self.up
|
143
|
+
add_column :jobs, :last_error, :text
|
144
|
+
end
|
145
|
+
|
146
|
+
def self.down
|
147
|
+
remove_column :jobs, :last_error
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
class ApplicationJob < ActiveJob::Base
|
152
|
+
attr_reader :last_error
|
153
|
+
|
154
|
+
def last_error=(error)
|
155
|
+
if error.is_a?(Exception)
|
156
|
+
backtrace = error.backtrace&.map { |line| "\t#{line}" }&.join("\n")
|
157
|
+
error = backtrace ? "#{error.class}: #{error.message}\n#{backtrace}" : "#{error.class}: #{error.message}"
|
158
|
+
end
|
159
|
+
|
160
|
+
@last_error = error&.truncate(10_000, omission: '… (truncated)')
|
161
|
+
end
|
162
|
+
|
163
|
+
def set(options = {})
|
164
|
+
super.tap { self.last_error = options[:error] if options[:error] }
|
165
|
+
end
|
166
|
+
|
167
|
+
def serialize
|
168
|
+
super.merge('last_error' => @last_error)
|
169
|
+
end
|
170
|
+
|
171
|
+
def deserialize(job_data)
|
172
|
+
super.tap { self.last_error = job_data['last_error'] }
|
173
|
+
end
|
174
|
+
end
|
175
|
+
```
|
176
|
+
|
110
177
|
## ActiveJob Cheatsheet
|
111
178
|
|
112
179
|
### Configuring a Queue Adapter
|
@@ -127,7 +194,7 @@ SomeJob.queue_adapter = :foo # Instantiates FooAdapter
|
|
127
194
|
SomeJob.queue_adapter = FooAdapter.new # Uses FooAdapter directly
|
128
195
|
```
|
129
196
|
|
130
|
-
|
197
|
+
### Configuration
|
131
198
|
|
132
199
|
- `config.active_job.default_queue_name`
|
133
200
|
- `config.active_job.queue_name_prefix`
|
@@ -158,7 +225,7 @@ SomeJob.queue_adapter = FooAdapter.new # Uses FooAdapter directly
|
|
158
225
|
- `ActiveJob::Callbacks.singleton_class.set_callback(:execute, :after, &block)`
|
159
226
|
- `ActiveJob::Callbacks.singleton_class.set_callback(:execute, :around, &block)`
|
160
227
|
|
161
|
-
|
228
|
+
### Handling Exceptions
|
162
229
|
|
163
230
|
- `SomeJob.retry_on`
|
164
231
|
- `SomeJob.discard_on`
|
@@ -207,9 +274,9 @@ SomeJob.set(options).perform_all_later(SomeJob.new, SomeJob.new, options:)
|
|
207
274
|
# Executed without enqueueing, enqueued on failure if retries configured
|
208
275
|
SomeJob.new(args).perform_now
|
209
276
|
SomeJob.perform_now(args)
|
210
|
-
ActiveJob::Base.
|
277
|
+
ActiveJob::Base.execute(SomeJob.new(args).serialize)
|
211
278
|
|
212
279
|
# Executed after enqueueing
|
213
280
|
SomeJob.perform_later(args).perform_now
|
214
|
-
ActiveJob::Base.
|
281
|
+
ActiveJob::Base.execute(SomeJob.perform_later(args).serialize)
|
215
282
|
```
|
data/app/models/marj.rb
CHANGED
@@ -1,18 +1,70 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'active_job'
|
3
4
|
require 'active_record'
|
5
|
+
require_relative '../../lib/marj_config'
|
4
6
|
|
5
7
|
# Marj is a Minimal ActiveRecord-based Jobs library.
|
6
8
|
#
|
7
9
|
# See https://github.com/nicholasdower/marj
|
8
10
|
class Marj < ActiveRecord::Base
|
9
11
|
# The Marj version.
|
10
|
-
VERSION = '
|
12
|
+
VERSION = '2.0.0'
|
11
13
|
|
12
|
-
|
13
|
-
|
14
|
+
# Executes the job associated with this record and returns the result.
|
15
|
+
def execute
|
16
|
+
# Normally we would call ActiveJob::Base#execute which has the following implementation:
|
17
|
+
# ActiveJob::Callbacks.run_callbacks(:execute) do
|
18
|
+
# job = deserialize(job_data)
|
19
|
+
# job.perform_now
|
20
|
+
# end
|
21
|
+
# However, we need to instantiate the job ourselves in order to register callbacks before execution.
|
22
|
+
ActiveJob::Callbacks.run_callbacks(:execute) do
|
23
|
+
# See register_callbacks for details on how callbacks are used.
|
24
|
+
job = job_class.new.tap { Marj.send(:register_callbacks, _1, self) }
|
25
|
+
|
26
|
+
# ActiveJob::Base#deserialize expects serialized arguments. But the record arguments have already been
|
27
|
+
# deserialized by a custom ActiveRecord serializer (see below). So instead we use the raw arguments string.
|
28
|
+
job_data = attributes.merge('arguments' => JSON.parse(read_attribute_before_type_cast(:arguments)))
|
29
|
+
|
30
|
+
# ActiveJob::Base#deserialize expects dates to be strings rather than Time objects.
|
31
|
+
job_data = job_data.to_h { |k, v| [k, %w[enqueued_at scheduled_at].include?(k) ? v&.iso8601 : v] }
|
32
|
+
job.deserialize(job_data)
|
33
|
+
|
34
|
+
new_executions = executions + 1
|
35
|
+
job.perform_now.tap do
|
36
|
+
# If no error was raised, the job should either be destroyed (success) or updated (retryable failure).
|
37
|
+
raise "job #{job_id} not destroyed or updated" unless destroyed? || (executions == new_executions && !changed?)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Returns an ActiveRecord::Relation scope for enqueued jobs with a +scheduled_at+ that is either +null+ or in the
|
43
|
+
# past. Jobs are ordered by +priority+ (+null+ last), then +scheduled_at+ (+null+ last), then +enqueued_at+.
|
44
|
+
#
|
45
|
+
# @return [ActiveRecord::Relation]
|
46
|
+
def self.ready
|
47
|
+
where('scheduled_at is null or scheduled_at <= ?', Time.now.utc).order(
|
48
|
+
Arel.sql(<<~SQL.squish)
|
49
|
+
CASE WHEN priority IS NULL THEN 1 ELSE 0 END, priority,
|
50
|
+
CASE WHEN scheduled_at IS NULL THEN 1 ELSE 0 END, scheduled_at,
|
51
|
+
enqueued_at
|
52
|
+
SQL
|
53
|
+
)
|
54
|
+
end
|
55
|
+
|
56
|
+
self.table_name = MarjConfig.table_name
|
14
57
|
|
58
|
+
# Order by +enqueued_at+ rather than +job_id+ (the default)
|
59
|
+
self.implicit_order_column = 'enqueued_at'
|
60
|
+
|
61
|
+
# Using a custom serializer for exception_executions so that we can interact with it as a hash rather than a string.
|
15
62
|
serialize(:exception_executions, coder: JSON)
|
63
|
+
|
64
|
+
# Using a custom serializer for arguments so that we can interact with as an array rather than a string.
|
65
|
+
# This enables code like:
|
66
|
+
# Marj.first.arguments.first
|
67
|
+
# Marj.first.update!(arguments: ['foo', 1, Time.now])
|
16
68
|
serialize(:arguments, coder: Class.new do
|
17
69
|
def self.dump(arguments)
|
18
70
|
return ActiveJob::Arguments.serialize(arguments).to_json if arguments.is_a?(Array)
|
@@ -25,6 +77,8 @@ class Marj < ActiveRecord::Base
|
|
25
77
|
arguments ? ActiveJob::Arguments.deserialize(JSON.parse(arguments)) : nil
|
26
78
|
end
|
27
79
|
end)
|
80
|
+
|
81
|
+
# Using a custom serializer for job_class so that we can interact with it as a class rather than a string.
|
28
82
|
serialize(:job_class, coder: Class.new do
|
29
83
|
def self.dump(clazz)
|
30
84
|
return clazz.name if clazz.is_a?(Class)
|
@@ -38,43 +92,17 @@ class Marj < ActiveRecord::Base
|
|
38
92
|
end
|
39
93
|
end)
|
40
94
|
|
41
|
-
# Returns an ActiveRecord::Relation scope for enqueued jobs with a +scheduled_at+ that is either +null+ or in the
|
42
|
-
# past. Jobs are ordered by +priority+ (+null+ last), then +scheduled_at+ (+null+ last), then +enqueued_at+.
|
43
|
-
#
|
44
|
-
# @return [ActiveRecord::Relation]
|
45
|
-
def self.available
|
46
|
-
where('scheduled_at is null or scheduled_at <= ?', Time.now.utc).order(
|
47
|
-
Arel.sql(<<~SQL.squish)
|
48
|
-
CASE WHEN priority IS NULL THEN 1 ELSE 0 END, priority,
|
49
|
-
CASE WHEN scheduled_at IS NULL THEN 1 ELSE 0 END, scheduled_at,
|
50
|
-
enqueued_at
|
51
|
-
SQL
|
52
|
-
)
|
53
|
-
end
|
54
|
-
|
55
|
-
# Executes any available jobs from the specified source.
|
56
|
-
#
|
57
|
-
# @param source [Proc] a job source
|
58
|
-
# @return [NilClass]
|
59
|
-
def self.work_off(source = -> { Marj.available.first })
|
60
|
-
while (record = source.call)
|
61
|
-
executions = record.executions
|
62
|
-
begin
|
63
|
-
record.execute
|
64
|
-
rescue Exception
|
65
|
-
# The job should either be discarded or updated. Otherwise, something went wrong.
|
66
|
-
raise unless record.destroyed? || (record.executions == (executions + 1) && !record.changed?)
|
67
|
-
end
|
68
|
-
end
|
69
|
-
end
|
70
|
-
|
71
95
|
# Registers job callbacks used to keep the database record for the specified job in sync.
|
72
96
|
#
|
73
97
|
# @param job [ActiveJob::Base]
|
74
98
|
# @return [ActiveJob::Base]
|
75
99
|
def self.register_callbacks(job, record)
|
76
|
-
|
100
|
+
raise 'callbacks already registered' if job.singleton_class.instance_variable_get(:@__marj)
|
77
101
|
|
102
|
+
# We need to detect three cases:
|
103
|
+
# - If a job succeeds, after_perform will be called.
|
104
|
+
# - If a job fails and should be retried, enqueue will be called. This is handled by the enqueue method.
|
105
|
+
# - If a job exceeds its max attempts, after_discard will be called.
|
78
106
|
job.singleton_class.after_perform { |_j| record.destroy! }
|
79
107
|
job.singleton_class.after_discard { |_j, _exception| record.destroy! }
|
80
108
|
job.singleton_class.instance_variable_set(:@__marj, record)
|
@@ -89,22 +117,31 @@ class Marj < ActiveRecord::Base
|
|
89
117
|
# @return [ActiveJob::Base] the enqueued job
|
90
118
|
def self.enqueue(job, time = nil)
|
91
119
|
job.scheduled_at = time
|
120
|
+
# Argument serialization is done by ActiveJob. ActiveRecord expects deserialized arguments.
|
92
121
|
serialized = job.serialize.symbolize_keys!.without(:provider_job_id).merge(arguments: job.arguments)
|
122
|
+
|
123
|
+
# When a job is enqueued, we must create/update the corresponding database record. We also must ensure callbacks are
|
124
|
+
# registered on the job instance so that when the job is executed, the database record is deleted or updated
|
125
|
+
# (depending on the result).
|
126
|
+
#
|
127
|
+
# There are three cases:
|
128
|
+
# - The first time a job is enqueued, we need to create the record and register callbacks.
|
129
|
+
# - If a previously enqueued job instance is re-enqueued, for instance after execution fails, callbacks have
|
130
|
+
# already been registered. In this case we only need to update the record.
|
131
|
+
# - It is also possible for new job instance to be created for a job that is already in the database. In this case
|
132
|
+
# we need to update the record and register callbacks.
|
133
|
+
#
|
134
|
+
# We keep track of whether callbacks have been registered by setting the @__marj instance variable on the job's
|
135
|
+
# singleton class. This holds a reference to the record. This allows us to update the record without re-fetching it
|
136
|
+
# and also ensures that if execute is called on a record any updates to the database are reflected on that record
|
137
|
+
# instance.
|
93
138
|
if (record = job.singleton_class.instance_variable_get(:@__marj))
|
94
139
|
record.update!(serialized)
|
95
140
|
else
|
96
|
-
record = Marj.
|
141
|
+
record = Marj.find_or_create_by!(job_id: job.job_id) { _1.assign_attributes(serialized) }
|
142
|
+
register_callbacks(job, record)
|
97
143
|
end
|
98
|
-
|
144
|
+
job
|
99
145
|
end
|
100
146
|
private_class_method :enqueue
|
101
|
-
|
102
|
-
# Executes the job associated with this record and returns the result.
|
103
|
-
def execute
|
104
|
-
job = Marj.send(:register_callbacks, job_class.new, self)
|
105
|
-
job_data = attributes.merge('arguments' => JSON.parse(read_attribute_before_type_cast(:arguments)))
|
106
|
-
job_data = job_data.to_h { |k, v| [k, %w[enqueued_at scheduled_at].include?(k) ? v&.iso8601 : v] }
|
107
|
-
job.deserialize(job_data)
|
108
|
-
ActiveJob::Callbacks.run_callbacks(:execute) { job.perform_now }
|
109
|
-
end
|
110
147
|
end
|
data/lib/marj.rb
CHANGED
@@ -1,24 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
# Enqueue a job for immediate execution.
|
6
|
-
#
|
7
|
-
# @param job [ActiveJob::Base] the job to enqueue
|
8
|
-
# @return [ActiveJob::Base] the enqueued job
|
9
|
-
def enqueue(job)
|
10
|
-
Marj.send(:enqueue, job)
|
11
|
-
end
|
3
|
+
require_relative 'marj_adapter'
|
4
|
+
require_relative 'marj_config'
|
12
5
|
|
13
|
-
|
14
|
-
#
|
15
|
-
# @param job [ActiveJob::Base] the job to enqueue
|
16
|
-
# @param timestamp [Numeric, NilClass] optional number of seconds since Unix epoch at which to execute the job
|
17
|
-
# @return [ActiveJob::Base] the enqueued job
|
18
|
-
def enqueue_at(job, timestamp)
|
19
|
-
Marj.send(:enqueue, job, timestamp ? Time.at(timestamp).utc : nil)
|
20
|
-
end
|
21
|
-
end
|
22
|
-
|
23
|
-
# Enable auto-loading when running in Rails.
|
24
|
-
class MarjEngine < Rails::Engine; end if defined?(Rails)
|
6
|
+
Kernel.autoload(:Marj, File.expand_path('../app/models/marj.rb', __dir__))
|
data/lib/marj_adapter.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# ActiveJob queue adapter for Marj.
|
4
|
+
class MarjAdapter
|
5
|
+
# Enqueue a job for immediate execution.
|
6
|
+
#
|
7
|
+
# @param job [ActiveJob::Base] the job to enqueue
|
8
|
+
# @return [ActiveJob::Base] the enqueued job
|
9
|
+
def enqueue(job)
|
10
|
+
Marj.send(:enqueue, job)
|
11
|
+
end
|
12
|
+
|
13
|
+
# Enqueue a job for execution at the specified time.
|
14
|
+
#
|
15
|
+
# @param job [ActiveJob::Base] the job to enqueue
|
16
|
+
# @param timestamp [Numeric, NilClass] optional number of seconds since Unix epoch at which to execute the job
|
17
|
+
# @return [ActiveJob::Base] the enqueued job
|
18
|
+
def enqueue_at(job, timestamp)
|
19
|
+
Marj.send(:enqueue, job, timestamp ? Time.at(timestamp).utc : nil)
|
20
|
+
end
|
21
|
+
end
|
data/lib/marj_config.rb
ADDED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: marj
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nick Dower
|
@@ -48,14 +48,15 @@ files:
|
|
48
48
|
- README.md
|
49
49
|
- app/models/marj.rb
|
50
50
|
- lib/marj.rb
|
51
|
-
- lib/
|
51
|
+
- lib/marj_adapter.rb
|
52
|
+
- lib/marj_config.rb
|
52
53
|
homepage: https://github.com/nicholasdower/marj
|
53
54
|
licenses:
|
54
55
|
- MIT
|
55
56
|
metadata:
|
56
57
|
bug_tracker_uri: https://github.com/nicholasdower/marj/issues
|
57
|
-
changelog_uri: https://github.com/nicholasdower/marj/releases/tag/
|
58
|
-
documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/
|
58
|
+
changelog_uri: https://github.com/nicholasdower/marj/releases/tag/v2.0.0
|
59
|
+
documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/v2.0.0
|
59
60
|
homepage_uri: https://github.com/nicholasdower/marj
|
60
61
|
rubygems_mfa_required: 'true'
|
61
62
|
source_code_uri: https://github.com/nicholasdower/marj
|
data/lib/marj/record.rb
DELETED