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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95b8fbdebe38a639f9b905df6ac1f5b853b09383d0810696c89c6c9e001efffe
4
- data.tar.gz: b7e92f9b98461e2f39122405b6e3278fb7ed8a0edec65963026f7e2665bb5042
3
+ metadata.gz: ff4783bfe9e3ff6a03683ed14a9f21a5227f6114bc5824cdee71a66f252d7fa1
4
+ data.tar.gz: d905d8ad1848d77ae9fade6b75527ae113675ba3c39b66149c750f9adb64d83b
5
5
  SHA512:
6
- metadata.gz: 13b5ea59163e833f5b698625d841fdcdc0a630b46880526df3274926e696b227b41946fd6622e121b9afd90ad3cc94589f6e864188e871d70f03c77168e3ad5e
7
- data.tar.gz: 27e5b74147706f227875f4f4a61f084298c42e615149cb0a74670af841df8fcce9eb4f95d11935561912af61d6d8759c21c9ca9862e897f765b49c78b1e42ebd
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- acidic_job (1.0.0.pre10)
4
+ acidic_job (1.0.0.pre13)
5
5
  activerecord (>= 6.1.0)
6
6
  activesupport
7
7
 
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 = {}, idempotency_key: nil, unique_by: nil)
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
- # use either [1] provided key, [2] provided uniqueness constraint, or [3] computed key
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
- # use either [1] provided key, [2] provided uniqueness constraint, or [3] computed key
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, idempotency_key: nil, unique_by: nil)
10
- new.deliver_acidicly(recipients, idempotency_key: idempotency_key, unique_by: unique_by)
9
+ def deliver_acidicly(recipients)
10
+ new.deliver_acidicly(recipients)
11
11
  end
12
12
  end
13
13
 
14
- def deliver_acidicly(recipients, idempotency_key: nil, unique_by: nil)
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
- # use either [1] provided key, [2] provided uniqueness constraint, or [3] computed key
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 = [], _kwargs = nil)
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
- # use either [1] provided key, [2] provided uniqueness constraint, or [3] computed key
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
- return value_from_job_args(hash_or_job, *args, **kwargs) if @identifier == :job_args
15
-
16
- value = if hash_or_job.is_a?(Hash)
17
- value_from_job_id_for_hash(hash_or_job)
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
- value_from_job_id_for_obj(hash_or_job)
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
- super_method = method(:perform).super_method
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 aj_job?
11
- __acidic_job_perform_for_aj(super_method, *args, **kwargs)
12
- elsif sk_job?
13
- __acidic_job_perform_for_sk(super_method, *args, **kwargs)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
- VERSION = "1.0.0.pre10"
4
+ VERSION = "1.0.0.pre13"
5
5
  end
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, @__acidic_job_args, @__acidic_job_kwargs)
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(@__acidic_job_args, @__acidic_job_kwargs)
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.pre10
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-13 00:00:00.000000000 Z
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