acidic_job 1.0.0.pre10 → 1.0.0.pre13
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.tool-versions +1 -0
- data/Gemfile.lock +1 -1
- data/README.md +49 -0
- data/lib/acidic_job/extensions/action_mailer.rb +2 -9
- data/lib/acidic_job/extensions/active_job.rb +1 -9
- data/lib/acidic_job/extensions/noticed.rb +4 -11
- data/lib/acidic_job/extensions/sidekiq.rb +3 -10
- data/lib/acidic_job/idempotency_key.rb +17 -9
- data/lib/acidic_job/perform_wrapper.rb +8 -35
- data/lib/acidic_job/version.rb +1 -1
- data/lib/acidic_job.rb +16 -2
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ff4783bfe9e3ff6a03683ed14a9f21a5227f6114bc5824cdee71a66f252d7fa1
|
4
|
+
data.tar.gz: d905d8ad1848d77ae9fade6b75527ae113675ba3c39b66149c750f9adb64d83b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1af39ccff6bb5d414ab32344887d60f4cf721d9272ec0c98c9686053d3426fd2da98d82b9fe35fd45ebc6b719b8e1df6ce922abbec86653a91bea736f9522696
|
7
|
+
data.tar.gz: f0f8db378646cd3e948a2406c2237b44c9e6a551dafcbe1a687cbfadc5a15713e3b6a177a18ccf81146b024cb64b44085a14c8250fd8b364f16c911d3a202f5b
|
data/.tool-versions
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
ruby 3.0.2
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
# AcidicJob
|
2
2
|
|
3
|
+
[![Gem Version](https://badge.fury.io/rb/acidic_job.svg)](https://badge.fury.io/rb/acidic_job)
|
4
|
+
![main workflow](https://github.com/fractaledmind/acidic_job/actions/workflows/main.yml/badge.svg)
|
5
|
+
|
3
6
|
### Idempotent operations for Rails apps (for ActiveJob or Sidekiq)
|
4
7
|
|
5
8
|
At the conceptual heart of basically any software are "operations"—the discrete actions the software performs. Rails provides a powerful abstraction layer for building operations in the form of `ActiveJob`, or we Rubyists can use the tried and true power of pure `Sidekiq`. With either we can easily trigger from other Ruby code throughout our Rails application (controller actions, model methods, model callbacks, etc.); we can run operations both synchronously (blocking execution and then returning its response to the caller) and asychronously (non-blocking and the caller doesn't know its response); and we can also retry a specific operation if needed seamlessly.
|
@@ -64,6 +67,7 @@ It provides a suite of functionality that empowers you to create complex, robust
|
|
64
67
|
* Transactional Steps — break your job into a series of steps, each of which will be run within an acidic database transaction, allowing retries to jump back to the last "recovery point".
|
65
68
|
* Persisted Attributes — when retrying jobs at later steps, we need to ensure that data created in previous steps is still available to later steps on retry.
|
66
69
|
* Transactionally Staged Jobs — enqueue additional jobs within the acidic transaction safely
|
70
|
+
* Custom Idempotency Keys — use something other than the job ID for the idempotency key of the job run
|
67
71
|
* Sidekiq Callbacks — bring ActiveJob-like callbacks into your pure Sidekiq Workers
|
68
72
|
* Sidekiq Batches — leverage the power of Sidekiq Pro's `batch` functionality without the hassle
|
69
73
|
|
@@ -163,6 +167,51 @@ class RideCreateJob < ActiveJob::Base
|
|
163
167
|
end
|
164
168
|
```
|
165
169
|
|
170
|
+
### Custom Idempotency Keys
|
171
|
+
|
172
|
+
By default, `AcidicJob` uses the job identifier provided by the queueing system (ActiveJob or Sidekiq) as the idempotency key for the job run. The idempotency key is what is used to guarantee that no two runs of the same job occur. However, sometimes we need particular jobs to be idempotent based on some other criteria. In these cases, `AcidicJob` provides a collection of tools to allow you to ensure the idempotency of your jobs.
|
173
|
+
|
174
|
+
Firstly, you can configure your job class to explicitly use either the job identifier or the job arguments as the foundation for the idempotency key. A job class that calls the `acidic_by_job_id` class method (which is the default behavior) will simply make the job run's idempotency key the job's identifier:
|
175
|
+
|
176
|
+
```ruby
|
177
|
+
class ExampleJob < ActiveJob::Base
|
178
|
+
include AcidicJob
|
179
|
+
acidic_by_job_id
|
180
|
+
|
181
|
+
def perform
|
182
|
+
end
|
183
|
+
end
|
184
|
+
```
|
185
|
+
|
186
|
+
Conversely, a job class can use the `acidic_by_job_args` method to configure that job class to use the arguments passed to the job as the foundation for the job run's idempotency key:
|
187
|
+
|
188
|
+
```ruby
|
189
|
+
class ExampleJob < ActiveJob::Base
|
190
|
+
include AcidicJob
|
191
|
+
acidic_by_job_args
|
192
|
+
|
193
|
+
def perform(arg_1, arg_2)
|
194
|
+
# the idempotency key will be based on whatever the values of `arg_1` and `arg_2` are
|
195
|
+
end
|
196
|
+
end
|
197
|
+
```
|
198
|
+
|
199
|
+
These options cover the two common situations, but sometimes our systems need finer-grained control. For example, our job might take some record as the job argument, but we need to use a combination of the record identifier and record status as the foundation for the idempotency key. In these cases you can pass a `Proc` to an `acidic_by` class method:
|
200
|
+
|
201
|
+
```ruby
|
202
|
+
class ExampleJob < ActiveJob::Base
|
203
|
+
include AcidicJob
|
204
|
+
acidic_by ->(record:) { [record.id, record.status] }
|
205
|
+
|
206
|
+
def perform(record:)
|
207
|
+
# the idempotency key will be based on whatever the values of `record.id` and `record.status` are
|
208
|
+
end
|
209
|
+
end
|
210
|
+
```
|
211
|
+
|
212
|
+
> **Note:** The signature of the `acidic_by` proc _needs to match the signature_ of the job's `perform` method.
|
213
|
+
|
214
|
+
|
166
215
|
### Sidekiq Callbacks
|
167
216
|
|
168
217
|
In order to ensure that `AcidicJob::Staged` records are only destroyed once the related job has been successfully performed, whether it is an ActiveJob or a Sidekiq Worker, `acidic_job` also extends Sidekiq to support the [ActiveJob callback interface](https://edgeguides.rubyonrails.org/active_job_basics.html#callbacks).
|
@@ -7,21 +7,14 @@ module AcidicJob
|
|
7
7
|
module ActionMailer
|
8
8
|
extend ActiveSupport::Concern
|
9
9
|
|
10
|
-
def deliver_acidicly(_options = {}
|
10
|
+
def deliver_acidicly(_options = {})
|
11
11
|
job_class = ::ActionMailer::MailDeliveryJob
|
12
12
|
|
13
13
|
job_args = [@mailer_class.name, @action.to_s, "deliver_now", @params, *@args]
|
14
14
|
# for Sidekiq, this depends on the Sidekiq::Serialization extension
|
15
15
|
serialized_job = job_class.new(job_args).serialize
|
16
16
|
acidic_identifier = job_class.respond_to?(:acidic_identifier) ? job_class.acidic_identifier : :job_id
|
17
|
-
|
18
|
-
key = if idempotency_key
|
19
|
-
idempotency_key
|
20
|
-
elsif unique_by
|
21
|
-
IdempotencyKey.generate(unique_by: unique_by, job_class: job_class.name)
|
22
|
-
else
|
23
|
-
IdempotencyKey.new(acidic_identifier).value_for(serialized_job)
|
24
|
-
end
|
17
|
+
key = IdempotencyKey.new(acidic_identifier).value_for(serialized_job)
|
25
18
|
|
26
19
|
AcidicJob::Run.create!(
|
27
20
|
staged: true,
|
@@ -24,15 +24,7 @@ module AcidicJob
|
|
24
24
|
raise UnsupportedExtension unless defined?(::ActiveJob) && self < ::ActiveJob::Base
|
25
25
|
|
26
26
|
serialized_job = serialize_with_arguments(*args, **kwargs)
|
27
|
-
|
28
|
-
key = if kwargs.key?(:idempotency_key) || kwargs.key?("idempotency_key")
|
29
|
-
kwargs[:idempotency_key] || kwargs["idempotency_key"]
|
30
|
-
elsif kwargs.key?(:unique_by) || kwargs.key?("unique_by")
|
31
|
-
unique_by = [kwargs[:unique_by], kwargs["unique_by"]].compact.first
|
32
|
-
IdempotencyKey.generate(unique_by: unique_by, job_class: name)
|
33
|
-
else
|
34
|
-
IdempotencyKey.new(acidic_identifier).value_for(serialized_job)
|
35
|
-
end
|
27
|
+
key = IdempotencyKey.new(acidic_identifier).value_for(serialized_job)
|
36
28
|
|
37
29
|
AcidicJob::Run.create!(
|
38
30
|
staged: true,
|
@@ -6,12 +6,12 @@ module AcidicJob
|
|
6
6
|
extend ActiveSupport::Concern
|
7
7
|
|
8
8
|
class_methods do
|
9
|
-
def deliver_acidicly(recipients
|
10
|
-
new.deliver_acidicly(recipients
|
9
|
+
def deliver_acidicly(recipients)
|
10
|
+
new.deliver_acidicly(recipients)
|
11
11
|
end
|
12
12
|
end
|
13
13
|
|
14
|
-
def deliver_acidicly(recipients
|
14
|
+
def deliver_acidicly(recipients)
|
15
15
|
# THIS IS A HACK THAT COPIES AND PASTES KEY PARTS OF THE `Noticed::Base` CODE
|
16
16
|
# IN ORDER TO ALLOW US TO TRANSACTIONALLY DELIVER NOTIFICATIONS
|
17
17
|
# THIS IS THUS LIABLE TO BREAK WHENEVER THAT GEM IS UPDATED
|
@@ -37,14 +37,7 @@ module AcidicJob
|
|
37
37
|
}
|
38
38
|
serialized_job = job_class.send(:job_or_instantiate, args).serialize
|
39
39
|
acidic_identifier = job_class.respond_to?(:acidic_identifier) ? job_class.acidic_identifier : :job_id
|
40
|
-
|
41
|
-
key = if idempotency_key
|
42
|
-
idempotency_key
|
43
|
-
elsif unique_by
|
44
|
-
IdempotencyKey.generate(unique_by: unique_by, job_class: job_class.name)
|
45
|
-
else
|
46
|
-
IdempotencyKey.new(acidic_identifier).value_for(serialized_job)
|
47
|
-
end
|
40
|
+
key = IdempotencyKey.new(acidic_identifier).value_for(serialized_job)
|
48
41
|
|
49
42
|
AcidicJob::Run.create!(
|
50
43
|
staged: true,
|
@@ -34,9 +34,10 @@ module AcidicJob
|
|
34
34
|
end
|
35
35
|
end
|
36
36
|
|
37
|
-
def serialize_job(args
|
37
|
+
def serialize_job(*args, **kwargs)
|
38
38
|
# `@args` is only set via `deserialize`; it is not a standard Sidekiq thing
|
39
39
|
arguments = args || @args
|
40
|
+
arguments += [kwargs] unless kwargs.empty?
|
40
41
|
normalized_args = ::Sidekiq.load_json(::Sidekiq.dump_json(arguments))
|
41
42
|
item = { "class" => self.class, "args" => normalized_args, "jid" => jid }
|
42
43
|
sidekiq_options = sidekiq_options_hash || {}
|
@@ -58,15 +59,7 @@ module AcidicJob
|
|
58
59
|
class_methods do
|
59
60
|
def perform_acidicly(*args, **kwargs)
|
60
61
|
serialized_job = serialize_with_arguments(*args, **kwargs)
|
61
|
-
|
62
|
-
key = if kwargs.key?(:idempotency_key) || kwargs.key?("idempotency_key")
|
63
|
-
kwargs[:idempotency_key] || kwargs["idempotency_key"]
|
64
|
-
elsif kwargs.key?(:unique_by) || kwargs.key?("unique_by")
|
65
|
-
unique_by = [kwargs[:unique_by], kwargs["unique_by"]].compact.first
|
66
|
-
IdempotencyKey.generate(unique_by: unique_by, job_class: name)
|
67
|
-
else
|
68
|
-
IdempotencyKey.new(acidic_identifier).value_for(serialized_job)
|
69
|
-
end
|
62
|
+
key = IdempotencyKey.new(acidic_identifier).value_for(serialized_job)
|
70
63
|
|
71
64
|
AcidicJob::Run.create!(
|
72
65
|
staged: true,
|
@@ -2,21 +2,22 @@
|
|
2
2
|
|
3
3
|
module AcidicJob
|
4
4
|
class IdempotencyKey
|
5
|
-
def self.generate(unique_by:, job_class:)
|
6
|
-
new(:job_args).value_for({ "job_class" => job_class }, Marshal.dump(unique_by))
|
7
|
-
end
|
8
|
-
|
9
5
|
def initialize(identifier = :job_id)
|
10
6
|
@identifier = identifier
|
11
7
|
end
|
12
8
|
|
13
9
|
def value_for(hash_or_job, *args, **kwargs)
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
10
|
+
value = case @identifier
|
11
|
+
when Proc
|
12
|
+
value_from_proc(hash_or_job, *args, **kwargs)
|
13
|
+
when :job_args
|
14
|
+
value_from_job_args(hash_or_job, *args, **kwargs)
|
18
15
|
else
|
19
|
-
|
16
|
+
if hash_or_job.is_a?(Hash)
|
17
|
+
value_from_job_id_for_hash(hash_or_job)
|
18
|
+
else
|
19
|
+
value_from_job_id_for_obj(hash_or_job)
|
20
|
+
end
|
20
21
|
end
|
21
22
|
|
22
23
|
value || value_from_job_args(hash_or_job, *args, **kwargs)
|
@@ -62,5 +63,12 @@ module AcidicJob
|
|
62
63
|
|
63
64
|
Digest::SHA1.hexdigest [worker_class, args, kwargs].flatten.join
|
64
65
|
end
|
66
|
+
|
67
|
+
def value_from_proc(_hash_or_job, *args, **kwargs)
|
68
|
+
return if args.empty? && kwargs.empty?
|
69
|
+
|
70
|
+
idempotency_args = Array(@identifier.call(*args, **kwargs))
|
71
|
+
Digest::SHA1.hexdigest idempotency_args.flatten.join
|
72
|
+
end
|
65
73
|
end
|
66
74
|
end
|
@@ -4,46 +4,19 @@ module AcidicJob
|
|
4
4
|
# NOTE: it is essential that this be a bare module and not an ActiveSupport::Concern
|
5
5
|
module PerformWrapper
|
6
6
|
def perform(*args, **kwargs)
|
7
|
-
|
7
|
+
@__acidic_job_args = args
|
8
|
+
@__acidic_job_kwargs = kwargs
|
8
9
|
|
9
10
|
# we don't want to run the `perform` callbacks twice, since ActiveJob already handles that for us
|
10
|
-
if
|
11
|
-
|
12
|
-
elsif
|
13
|
-
|
11
|
+
if defined?(ActiveJob) && self.class < ActiveJob::Base
|
12
|
+
super(*args, **kwargs)
|
13
|
+
elsif defined?(Sidekiq) && self.class.include?(Sidekiq::Worker)
|
14
|
+
run_callbacks :perform do
|
15
|
+
super(*args, **kwargs)
|
16
|
+
end
|
14
17
|
else
|
15
18
|
raise UnknownJobAdapter
|
16
19
|
end
|
17
20
|
end
|
18
|
-
|
19
|
-
def sk_job?
|
20
|
-
defined?(Sidekiq) && self.class.include?(Sidekiq::Worker)
|
21
|
-
end
|
22
|
-
|
23
|
-
def aj_job?
|
24
|
-
defined?(ActiveJob) && self.class < ActiveJob::Base
|
25
|
-
end
|
26
|
-
|
27
|
-
private
|
28
|
-
|
29
|
-
# don't run `perform` callbacks, as ActiveJob already does this
|
30
|
-
def __acidic_job_perform_for_aj(super_method, *args, **kwargs)
|
31
|
-
__acidic_job_perform_base(super_method, *args, **kwargs)
|
32
|
-
end
|
33
|
-
|
34
|
-
# ensure to run `perform` callbacks
|
35
|
-
def __acidic_job_perform_for_sk(super_method, *args, **kwargs)
|
36
|
-
run_callbacks :perform do
|
37
|
-
__acidic_job_perform_base(super_method, *args, **kwargs)
|
38
|
-
end
|
39
|
-
end
|
40
|
-
|
41
|
-
# capture arguments passed to `perform` to be used by AcidicJob later
|
42
|
-
def __acidic_job_perform_base(super_method, *args, **kwargs)
|
43
|
-
@__acidic_job_args = args
|
44
|
-
@__acidic_job_kwargs = kwargs
|
45
|
-
|
46
|
-
super_method.call(*args, **kwargs)
|
47
|
-
end
|
48
21
|
end
|
49
22
|
end
|
data/lib/acidic_job/version.rb
CHANGED
data/lib/acidic_job.rb
CHANGED
@@ -50,6 +50,7 @@ module AcidicJob
|
|
50
50
|
klass.instance_variable_set(:@acidic_identifier, :job_id)
|
51
51
|
klass.define_singleton_method(:acidic_by_job_id) { @acidic_identifier = :job_id }
|
52
52
|
klass.define_singleton_method(:acidic_by_job_args) { @acidic_identifier = :job_args }
|
53
|
+
klass.define_singleton_method(:acidic_by) { |proc| @acidic_identifier = proc }
|
53
54
|
end
|
54
55
|
|
55
56
|
included do
|
@@ -67,6 +68,19 @@ module AcidicJob
|
|
67
68
|
end
|
68
69
|
end
|
69
70
|
|
71
|
+
def initialize(*args, **kwargs)
|
72
|
+
# ensure this instance variable is always defined
|
73
|
+
@__acidic_job_steps = []
|
74
|
+
@__acidic_job_args = args
|
75
|
+
@__acidic_job_kwargs = kwargs
|
76
|
+
|
77
|
+
if method(__method__).super_method.arity.zero?
|
78
|
+
super()
|
79
|
+
else
|
80
|
+
super(*args, **kwargs)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
70
84
|
def with_acidity(providing: {})
|
71
85
|
# ensure this instance variable is always defined
|
72
86
|
@__acidic_job_steps = []
|
@@ -107,7 +121,7 @@ module AcidicJob
|
|
107
121
|
|
108
122
|
acidic_identifier = self.class.acidic_identifier
|
109
123
|
@__acidic_job_idempotency_key ||= IdempotencyKey.new(acidic_identifier)
|
110
|
-
.value_for(self,
|
124
|
+
.value_for(self, *@__acidic_job_args, **@__acidic_job_kwargs)
|
111
125
|
end
|
112
126
|
# rubocop:enable Naming/MemoizedInstanceVariableName
|
113
127
|
|
@@ -191,7 +205,7 @@ module AcidicJob
|
|
191
205
|
|
192
206
|
ActiveRecord::Base.transaction(isolation: isolation_level) do
|
193
207
|
run = Run.find_by(idempotency_key: idempotency_key)
|
194
|
-
serialized_job = serialize_job(
|
208
|
+
serialized_job = serialize_job(*@__acidic_job_args, **@__acidic_job_kwargs)
|
195
209
|
|
196
210
|
if run.present?
|
197
211
|
# Programs enqueuing multiple jobs with different parameters but the
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: acidic_job
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.0.
|
4
|
+
version: 1.0.0.pre13
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- fractaledmind
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-03-
|
11
|
+
date: 2022-03-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -63,6 +63,7 @@ files:
|
|
63
63
|
- ".gitignore"
|
64
64
|
- ".rubocop.yml"
|
65
65
|
- ".ruby_version"
|
66
|
+
- ".tool-versions"
|
66
67
|
- Gemfile
|
67
68
|
- Gemfile.lock
|
68
69
|
- LICENSE
|