marj 1.0.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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'