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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5fd21edb773f1e25cc23e4099e7b0e6a3e2bb58485a988b063c37fdb71f31b82
4
- data.tar.gz: aadc1963f4283a092a000bf0c8f732cd57a53f0ed69ad4c83c8c4dbd053907a3
3
+ metadata.gz: c4feb80230bafd2d67bdb0316598f4c46eabd01ca86c35fb8b01e04c865f91a8
4
+ data.tar.gz: be461234b8359c9f974dd4160d896cc048d0dcc1f4b7d74b57042d62f9950a18
5
5
  SHA512:
6
- metadata.gz: 7ddc1d33db20fb9d9ee3a587a67e66c54dcffb8d7f04dc366f53d40605b3239642c5466196e27debe86b1a8c74e10138557f2797232324c7f7a4d49b5df0134c
7
- data.tar.gz: 04a4170a8e39b64648bf125da0f1fe1eae1e5888bd33412ff896dc0358f1a6c5b37bd4df1305fb9b2be715812318a49354b0eae1ccc756ceff65bf96879060cc
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
- ### 2. Create the jobs table
27
+ ```shell
28
+ bundle install
29
+ ```
26
30
 
27
- Apply a database migration:
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
- ### 3. Configure the queue adapter
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
- # Configure via Rails::Application:
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
- If not using Rails:
68
+ ### 3. Configure the queue adapter
75
69
 
76
70
  ```ruby
77
71
  require 'marj'
78
- require 'marj/record' # Loads ActiveRecord
79
72
 
80
- # Configure via ActiveJob::Base:
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.execute
96
+ record = Marj.first
97
+ record.execute
99
98
 
100
- # Run all available jobs:
101
- Marj.work_off
99
+ # Run all ready jobs:
100
+ while (record = Marj.ready.first)
101
+ record.execute
102
+ end
102
103
 
103
- # Run jobs as they become available:
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.work_off
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
- ## Configuration
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
- ## Handling Exceptions
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.exeucute(SomeJob.new(args).serialize)
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.exeucute(SomeJob.perform_later(args).serialize)
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 = '1.0.0'
12
+ VERSION = '2.0.0'
11
13
 
12
- self.table_name = 'jobs'
13
- self.implicit_order_column = 'enqueued_at' # Order by +enqueued_at+ rather than +job_id+ (the default)
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
- return if job.singleton_class.instance_variable_get(:@__marj)
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.find_by(job_id: job.job_id)&.update!(serialized) || Marj.create!(serialized)
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
- register_callbacks(job, record)
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
- # 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
3
+ require_relative 'marj_adapter'
4
+ require_relative 'marj_config'
12
5
 
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
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__))
@@ -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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Marj configuration.
4
+ class MarjConfig
5
+ @table_name = 'jobs'
6
+
7
+ class << self
8
+ # The name of the database table. Defaults to "jobs".
9
+ #
10
+ # @return [String]
11
+ attr_accessor :table_name
12
+ end
13
+ end
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: 1.0.0
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/marj/record.rb
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/v1.0.0
58
- documentation_uri: https://www.rubydoc.info/github/nicholasdower/marj/v1.0.0
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
@@ -1,3 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../../app/models/marj'