acidic_job 0.5.5 → 0.7.2

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: 79d8997dd3c0922319cbfc2408804a935e302a74c6fc0664811dce1a59bb272d
4
- data.tar.gz: bfce5d294e9831fff9e40fc8b6aa1cda59eb31262640f2530906b30b21ceea79
3
+ metadata.gz: 4910116a711c9df36b6266a92b97c71112bfd03ee6d403905f0f14ae61eea383
4
+ data.tar.gz: 8965848f4a6c0e0d4888f00c691572f5219d66bdf29b45cc317bb8294d2ea683
5
5
  SHA512:
6
- metadata.gz: c97fed527770260bfd63f1ef3702419c81168e9b2f08da603ed22e328d22f85dc0a745bdc523725317ac3ac77fa4a3005581cd82a7bc85cd573552ba158e473f
7
- data.tar.gz: 1e5a63b40b7e6b98e10bf9c98db06686b0468fd559ccdd0834309b2a2e5e40c4d9366632806c845e72cb43924fb34059155d67b2fa5bd5717ed5f6c523589082
6
+ metadata.gz: 93d676f01d6e46410bbb91d41ae16f2c50c71a0ec3202ea48a2808daf6775ab2802fec5191b9326c60aaaadf6c31e67260ca173d6827ffaa3eb566dee4acb881
7
+ data.tar.gz: 41c53dcc97dee5b14575fa83702dde9cb8eafaf0390cfa8d67ab75a1a7d62e1a2b213c02442a324818ef3fa816d2bfdf2a3f9ea21530cdf9567f5021405b1c20
data/.gitignore CHANGED
@@ -8,3 +8,4 @@
8
8
  /tmp/
9
9
  .DS_Store
10
10
  /test/database.sqlite
11
+ slides.md
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- acidic_job (0.5.5)
4
+ acidic_job (0.7.2)
5
5
  activerecord (>= 4.0.0)
6
6
  activesupport
7
7
 
@@ -62,8 +62,6 @@ GEM
62
62
  nokogiri (1.12.3)
63
63
  mini_portile2 (~> 2.6.1)
64
64
  racc (~> 1.4)
65
- nokogiri (1.12.3-x86_64-darwin)
66
- racc (~> 1.4)
67
65
  parallel (1.20.1)
68
66
  parser (3.0.1.1)
69
67
  ast (~> 2.4.1)
@@ -144,4 +142,4 @@ DEPENDENCIES
144
142
  sqlite3
145
143
 
146
144
  BUNDLED WITH
147
- 2.2.5
145
+ 2.2.31
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # AcidicJob
2
2
 
3
- ### Idempotent operations for Rails apps, built on top of ActiveJob.
3
+ ### Idempotent operations for Rails apps (for ActiveJob or Sidekiq)
4
4
 
5
- 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`. With `ActiveJob`, 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.
5
+ 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.
6
6
 
7
7
  However, in order to ensure that our operational jobs are _robust_, we need to ensure that they are properly [idempotent and transactional](https://github.com/mperham/sidekiq/wiki/Best-Practices#2-make-your-job-idempotent-and-transactional). As stated in the [GitLab Sidekiq Style Guide](https://docs.gitlab.com/ee/development/sidekiq_style_guide.html#idempotent-jobs):
8
8
 
@@ -37,7 +37,7 @@ Or simply execute to install the gem yourself:
37
37
 
38
38
  $ bundle add acidic_job
39
39
 
40
- Then, use the following command to copy over the AcidicJobKey migration.
40
+ Then, use the following command to copy over the `AcidicJob::Key` migration file as well as the `AcidicJob::Staged` migration file.
41
41
 
42
42
  ```
43
43
  rails generate acidic_job
@@ -45,7 +45,19 @@ rails generate acidic_job
45
45
 
46
46
  ## Usage
47
47
 
48
- `AcidicJob` is a concern that you `include` into your operation jobs which provides two public methods to help you make your jobs idempotent and robust—`idempotently` and `step`. You can see them "in action" in the example job below:
48
+ `AcidicJob` is a concern that you `include` into your operation jobs.
49
+
50
+ ```ruby
51
+ class RideCreateJob < ActiveJob::Base
52
+ include AcidicJob
53
+ end
54
+ ```
55
+
56
+ It provides a suite of functionality that empowers you to create complex, robust, and _acidic_ jobs.
57
+
58
+ ### Transactional Steps
59
+
60
+ The first and foundational feature `acidic_job` provides is the `idempotently` method, which takes a block of transactional step methods (defined via the `step`) method:
49
61
 
50
62
  ```ruby
51
63
  class RideCreateJob < ActiveJob::Base
@@ -75,7 +87,77 @@ end
75
87
 
76
88
  `idempotently` takes only the `with:` named parameter and a block where you define the steps of this operation. `step` simply takes the name of a method available in the job. That's all!
77
89
 
78
- So, how does `AcidicJob` make this operation idempotent and robust then? In simplest form, `AcidicJob` creates an "idempotency key" record for each job run, where it stores information about that job run, like the parameters passed in and the step the job is on. It then wraps each of your step methods in a database transaction to ensure that each step in the operation is transactionally secure. Finally, it handles a variety of edge-cases and error conditions for you as well. But, basically, by explicitly breaking your operation into steps and storing a record of each job run and updating its current step as it runs, we level up the `ActiveJob` retry mechanism to ensure that we don't retry already finished steps if something goes wrong and the job has to retry. Then, by wrapping each step in a transaction, we ensure each individual step is ACIDic. Taken together, these two strategies help us to ensure that our operational jobs are both idempotent and ACIDic.
90
+ Now, each execution of this job will find or create an `AcidicJob::Key` record, which we leverage to wrap every step in a database transaction. Moreover, this database record allows `acidic_job` to ensure that if your job fails on step 3, when it retries, it will simply jump right back to trying to execute the method defined for the 3rd step, and won't even execute the first two step methods. This means your step methods only need to be idempotent on failure, not on success, since they will never be run again if they succeed.
91
+
92
+ ### Persisted Attributes
93
+
94
+ Any objects passed to the `with` option on the `idempotently` method are not just made available to each of your step methods, they are made available across retries. This means that you can set an attribute in step 1, access it in step 2, have step 2 fail, have the job retry, jump directly back to step 2 on retry, and have that object still accessible. This is done by serializing all objects to a field on the `AcidicJob::Key` and manually providing getters and setters that sync with the database record.
95
+
96
+ ```ruby
97
+ class RideCreateJob < ActiveJob::Base
98
+ include AcidicJob
99
+
100
+ def perform(ride_params)
101
+ idempotently with: { ride: nil } do
102
+ step :create_ride_and_audit_record
103
+ step :create_stripe_charge
104
+ step :send_receipt
105
+ end
106
+ end
107
+
108
+ def create_ride_and_audit_record
109
+ self.ride = Ride.create!
110
+ end
111
+
112
+ def create_stripe_charge
113
+ Stripe::Charge.create(amount: 20_00, customer: @ride.user)
114
+ end
115
+
116
+ # ...
117
+ end
118
+ ```
119
+
120
+ **Note:** This does mean that you are restricted to objects that can be serialized by ActiveRecord, thus no Procs, for example.
121
+
122
+ **Note:** You will note the use of `self.ride = ...` in the code sample above. In order to call the attribute setter method that will sync with the database record, you _must_ use this style. `@ride = ...` and/or `ride = ...` will both fail to sync the value with the datbase record.
123
+
124
+ ### Transactionally Staged Jobs
125
+
126
+ A standard problem when inside of database transactions is enqueuing other jobs. On the one hand, you could enqueue a job inside of a transaction that then rollbacks, which would leave that job to fail and retry and fail. On the other hand, you could enqueue a job that is picked up before the transaction commits, which would mean the records are not yet available to this job.
127
+
128
+ In order to mitigate against such issues without forcing you to use a database-backed job queue, `acidic_job` provides `perform_transactionally` and `deliver_transactionally` methods to "transactionally stage" enqueuing other jobs from within a step (whether another ActiveJob or a Sidekiq::Worker or an ActionMailer delivery). These methods will create a new `AcidicJob::Staged` record, but inside of the database transaction of the `step`. Upon commit of that transaction, a model callback pushes the job to your actual job queue. Once the job has been successfully performed, the `AcidicJob::Staged` record is deleted so that this table doesn't grow unbounded and unnecessarily.
129
+
130
+ ```ruby
131
+ class RideCreateJob < ActiveJob::Base
132
+ include AcidicJob
133
+
134
+ def perform(ride_params)
135
+ idempotently with: { user: current_user, params: ride_params, ride: nil } do
136
+ step :create_ride_and_audit_record
137
+ step :create_stripe_charge
138
+ step :send_receipt
139
+ end
140
+ end
141
+
142
+ # ...
143
+
144
+ def send_receipt
145
+ RideMailer.with(ride: @ride, user: @user).confirm_charge.delivery_transactionally
146
+ end
147
+ end
148
+ ```
149
+
150
+ ### Sidekiq Callbacks
151
+
152
+ 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).
153
+
154
+ This allows `acidic_job` to use an `after_perform` callback to delete the `AcidicJob::Staged` record, whether you are using the gem with ActiveJob or pure Sidekiq Workers. Of course, this means that you can add your own callbacks to any jobs or workers that include the `AcidicJob` module as well.
155
+
156
+ ### Sidekiq Batches
157
+
158
+ One final feature for those of you using Sidekiq Pro: an integrated DSL for Sidekiq Batches. By simply adding the `awaits` option to your step declarations, you can attach any number of additional, asynchronous workers to your step. This is profoundly powerful, as it means that you can define a workflow where step 2 is started _if and only if_ step 1 succeeds, but step 1 can have 3 different workers enqueued on 3 different queues, each running in parallel. Once all 3 workers succeed, `acidic_job` will move on to step 2. That's right, by leveraging the power of Sidekiq Batches, you can have workers that are executed in parallel, on separate queues, and asynchronously, but are still blocking—as a group—the next step in your workflow! This unlocks incredible power and flexibility for defining and structuring complex workflows and operations, and in my mind is the number one selling point for Sidekiq Pro.
159
+
160
+ In my opinion, any commercial software using Sidekiq should get Sidekiq Pro; it is _absolutely_ worth the money. If, however, you are using `acidic_job` in a non-commercial application, you could use the open-source dropin replacement for this functionality: https://github.com/breamware/sidekiq-batch
79
161
 
80
162
  ## Development
81
163
 
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ module DeliverTransactionallyExtension
5
+ def deliver_transactionally(options = {})
6
+ job = delivery_job_class
7
+
8
+ attributes = {
9
+ adapter: "activejob",
10
+ job_name: job.name
11
+ }
12
+
13
+ job_args = if job <= ActionMailer::Parameterized::MailDeliveryJob
14
+ [@mailer_class.name, @action.to_s, "deliver_now", {params: @params, args: @args}]
15
+ else
16
+ [@mailer_class.name, @action.to_s, "deliver_now", @params, *@args]
17
+ end
18
+
19
+ attributes[:job_args] = job.new(job_args).serialize
20
+
21
+ AcidicJob::Staged.create!(attributes)
22
+ end
23
+ end
24
+ end
@@ -14,4 +14,12 @@ module AcidicJob
14
14
  class SerializedTransactionConflict < Error; end
15
15
 
16
16
  class UnknownJobAdapter < Error; end
17
+
18
+ class NoDefinedSteps < Error; end
19
+
20
+ class SidekiqBatchRequired < Error; end
21
+
22
+ class TooManyParametersForStepMethod < Error; end
23
+
24
+ class TooManyParametersForParallelJob < Error; end
17
25
  end
@@ -10,6 +10,7 @@ module AcidicJob
10
10
 
11
11
  serialize :error_object
12
12
  serialize :job_args
13
+ serialize :workflow
13
14
  store :attr_accessors
14
15
 
15
16
  validates :idempotency_key, presence: true, uniqueness: { scope: %i[job_name job_args] }
@@ -9,13 +9,13 @@ module AcidicJob
9
9
  class_methods do
10
10
  # rubocop:disable Metrics/MethodLength
11
11
  def perform_transactionally(*args)
12
- attributes = if self < ActiveJob::Base
12
+ attributes = if defined?(ActiveJob) && self < ActiveJob::Base
13
13
  {
14
14
  adapter: "activejob",
15
15
  job_name: name,
16
16
  job_args: job_or_instantiate(*args).serialize
17
17
  }
18
- elsif include? Sidekiq::Worker
18
+ elsif defined?(Sidekiq) && include?(Sidekiq::Worker)
19
19
  {
20
20
  adapter: "sidekiq",
21
21
  job_name: name,
@@ -3,6 +3,23 @@
3
3
  module AcidicJob
4
4
  module PerformWrapper
5
5
  def perform(*args, **kwargs)
6
+ # extract the `staged_job_gid` if present
7
+ # so that we can later delete the record in an `after_perform` callback
8
+ final_arg = args.last
9
+ if final_arg.is_a?(Hash) && final_arg.key?("staged_job_gid")
10
+ args = args[0..-2]
11
+ @staged_job_gid = final_arg["staged_job_gid"]
12
+ end
13
+
14
+ set_arguments_for_perform(*args, **kwargs)
15
+
16
+ super(*args, **kwargs)
17
+ end
18
+
19
+ private
20
+
21
+ # rubocop:disable Metrics/AbcSize
22
+ def set_arguments_for_perform(*args, **kwargs)
6
23
  # store arguments passed into `perform` so that we can later persist
7
24
  # them to `AcidicJob::Key#job_args` for both ActiveJob and Sidekiq::Worker
8
25
  @arguments_for_perform = if args.any? && kwargs.any?
@@ -14,8 +31,7 @@ module AcidicJob
14
31
  else
15
32
  []
16
33
  end
17
-
18
- super
19
34
  end
35
+ # rubocop:enable Metrics/AbcSize
20
36
  end
21
37
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/callbacks"
4
+
5
+ # Following approach used by ActiveJob
6
+ # https://github.com/rails/rails/blob/93c9534c9871d4adad4bc33b5edc355672b59c61/activejob/lib/active_job/callbacks.rb
7
+ module SidekiqCallbacks
8
+ extend ActiveSupport::Concern
9
+
10
+ def self.prepended(base)
11
+ base.include(ActiveSupport::Callbacks)
12
+
13
+ # Check to see if we already have any callbacks for :perform
14
+ # Prevents overwriting callbacks if we already included this module (and defined callbacks)
15
+ base.define_callbacks :perform unless base.respond_to?(:_perform_callbacks) && base._perform_callbacks.present?
16
+
17
+ class << base
18
+ prepend ClassMethods
19
+ end
20
+ end
21
+
22
+ def perform(*args)
23
+ if respond_to?(:run_callbacks)
24
+ run_callbacks :perform do
25
+ super(*args)
26
+ end
27
+ else
28
+ super(*args)
29
+ end
30
+ end
31
+
32
+ module ClassMethods
33
+ def around_perform(*filters, &blk)
34
+ set_callback(:perform, :around, *filters, &blk)
35
+ end
36
+
37
+ def before_perform(*filters, &blk)
38
+ set_callback(:perform, :before, *filters, &blk)
39
+ end
40
+
41
+ def after_perform(*filters, &blk)
42
+ set_callback(:perform, :after, *filters, &blk)
43
+ end
44
+ end
45
+ end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_record"
4
+ require "global_id"
4
5
 
5
6
  module AcidicJob
6
7
  class Staged < ActiveRecord::Base
7
8
  self.table_name = "staged_acidic_jobs"
8
9
 
10
+ include GlobalID::Identification
11
+
9
12
  validates :adapter, presence: true
10
13
  validates :job_name, presence: true
11
14
  validates :job_args, presence: true
@@ -14,19 +17,34 @@ module AcidicJob
14
17
 
15
18
  after_create_commit :enqueue_job
16
19
 
20
+ private
21
+
22
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
17
23
  def enqueue_job
24
+ gid = { "staged_job_gid" => to_global_id.to_s }
25
+
26
+ if job_args.is_a?(Hash) && job_args.key?("arguments")
27
+ job_args["arguments"].concat([gid])
28
+ else
29
+ job_args.concat([gid])
30
+ end
31
+
18
32
  case adapter
19
33
  when "activejob"
20
34
  job = ActiveJob::Base.deserialize(job_args)
21
35
  job.enqueue
22
36
  when "sidekiq"
23
- Sidekiq::Client.push("class" => job_name, "args" => job_args)
37
+ Sidekiq::Client.push(
38
+ "class" => job_name,
39
+ "args" => job_args
40
+ )
24
41
  else
25
42
  raise UnknownJobAdapter.new(adapter: adapter)
26
43
  end
27
44
 
28
- # TODO: ensure successful enqueuing before deletion
29
- delete
45
+ # NOTE: record will be deleted after the job has successfully been performed
46
+ true
30
47
  end
48
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
31
49
  end
32
50
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
- VERSION = "0.5.5"
4
+ VERSION = "0.7.2"
5
5
  end
data/lib/acidic_job.rb CHANGED
@@ -9,6 +9,8 @@ require_relative "acidic_job/key"
9
9
  require_relative "acidic_job/staged"
10
10
  require_relative "acidic_job/perform_wrapper"
11
11
  require_relative "acidic_job/perform_transactionally_extension"
12
+ require_relative "acidic_job/deliver_transactionally_extension"
13
+ require_relative "acidic_job/sidekiq_callbacks"
12
14
  require "active_support/concern"
13
15
 
14
16
  # rubocop:disable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
@@ -17,13 +19,22 @@ module AcidicJob
17
19
 
18
20
  def self.wire_everything_up(klass)
19
21
  klass.attr_reader :key
20
- klass.attr_accessor :arguments_for_perform
22
+ klass.attr_reader :staged_job_gid
23
+ klass.attr_reader :arguments_for_perform
21
24
 
22
25
  # Extend ActiveJob with `perform_transactionally` class method
23
26
  klass.include PerformTransactionallyExtension
24
27
 
28
+ if defined?(ActionMailer)
29
+ ActionMailer::Parameterized::MessageDelivery.include DeliverTransactionallyExtension
30
+ end
31
+
25
32
  # Ensure our `perform` method always runs first to gather parameters
26
33
  klass.prepend PerformWrapper
34
+
35
+ klass.prepend SidekiqCallbacks unless klass.respond_to?(:after_perform)
36
+
37
+ klass.after_perform :delete_staged_job_record, if: :staged_job_gid
27
38
  end
28
39
 
29
40
  included do
@@ -35,6 +46,14 @@ module AcidicJob
35
46
  AcidicJob.wire_everything_up(subclass)
36
47
  super
37
48
  end
49
+
50
+ def initiate(*args)
51
+ operation = Sidekiq::Batch.new
52
+ operation.on(:success, self, *args)
53
+ operation.jobs do
54
+ perform_async
55
+ end
56
+ end
38
57
  end
39
58
 
40
59
  # Number of seconds passed which we consider a held idempotency key lock to be
@@ -45,42 +64,54 @@ module AcidicJob
45
64
 
46
65
  # takes a block
47
66
  def idempotently(with:)
48
- # execute the block to gather the info on what phases are defined for this job
49
- defined_steps = yield
50
- # [:create_ride_and_audit_record, :create_stripe_charge, :send_receipt]
67
+ # execute the block to gather the info on what steps are defined for this job workflow
68
+ steps = yield || []
51
69
 
52
- # convert the array of steps into a hash of recovery_points and callable actions
53
- phases = define_atomic_phases(defined_steps)
54
- # { create_ride_and_audit_record: <#Method >, ... }
70
+ raise NoDefinedSteps if steps.empty?
55
71
 
56
- # find or create an Key record (our idempotency key) to store all information about this job
57
- # side-effect: will set the @key instance variable
72
+ # convert the array of steps into a hash of recovery_points and next steps
73
+ workflow = define_workflow(steps)
74
+
75
+ # find or create a Key record (our idempotency key) to store all information about this job
58
76
  #
59
77
  # A key concept here is that if two requests try to insert or update within
60
78
  # close proximity, one of the two will be aborted by Postgres because we're
61
79
  # using a transaction with SERIALIZABLE isolation level. It may not look
62
80
  # it, but this code is safe from races.
63
- ensure_idempotency_key_record(idempotency_key_value, defined_steps.first)
81
+ key = ensure_idempotency_key_record(idempotency_key_value, workflow, with)
82
+
83
+ # begin the workflow
84
+ process_key(key)
85
+ end
86
+
87
+ def process_key(key)
88
+ @key = key
64
89
 
65
90
  # if the key record is already marked as finished, immediately return its result
66
91
  return @key.succeeded? if @key.finished?
67
92
 
68
- # set accessors for each argument passed in to ensure they are available
69
- # to the step methods the job will have written
70
- define_accessors_for_passed_arguments(with, @key)
71
-
72
- # otherwise, we will enter a loop to process each required step of the job
73
- phases.size.times do
74
- # our `phases` hash uses Symbols for keys
75
- recovery_point = @key.recovery_point.to_sym
93
+ # otherwise, we will enter a loop to process each step of the workflow
94
+ @key.workflow.size.times do
95
+ recovery_point = @key.recovery_point.to_s
96
+ current_step = @key.workflow[recovery_point]
76
97
 
77
- case recovery_point
78
- when Key::RECOVERY_POINT_FINISHED.to_sym
98
+ if recovery_point == Key::RECOVERY_POINT_FINISHED.to_s
79
99
  break
100
+ elsif current_step.nil?
101
+ raise UnknownRecoveryPoint, "Defined workflow does not reference this step: #{recovery_point}"
102
+ elsif (jobs = current_step.fetch("awaits", [])).any?
103
+ acidic_step @key, current_step
104
+ # THIS MUST BE DONE AFTER THE KEY RECOVERY POINT HAS BEEN UPDATED
105
+ enqueue_step_parallel_jobs(jobs)
106
+ # after processing the current step, break the processing loop
107
+ # and stop this method from blocking in the primary worker
108
+ # as it will continue once the background workers all succeed
109
+ # so we want to keep the primary worker queue free to process new work
110
+ # this CANNOT ever be `break` as that wouldn't exit the parent job,
111
+ # only this step in the workflow, blocking as it awaits the next step
112
+ return true
80
113
  else
81
- raise UnknownRecoveryPoint unless phases.key? recovery_point
82
-
83
- atomic_phase @key, phases[recovery_point]
114
+ acidic_step @key, current_step
84
115
  end
85
116
  end
86
117
 
@@ -88,9 +119,14 @@ module AcidicJob
88
119
  @key.succeeded?
89
120
  end
90
121
 
91
- def step(method_name)
122
+ def step(method_name, awaits: [])
92
123
  @_steps ||= []
93
- @_steps << method_name
124
+
125
+ @_steps << {
126
+ "does" => method_name.to_s,
127
+ "awaits" => awaits
128
+ }
129
+
94
130
  @_steps
95
131
  end
96
132
 
@@ -102,35 +138,30 @@ module AcidicJob
102
138
 
103
139
  private
104
140
 
105
- def atomic_phase(key, proc = nil, &block)
106
- rescued_error = false
107
- phase_callable = (proc || block)
141
+ def delete_staged_job_record
142
+ return unless staged_job_gid
108
143
 
109
- begin
110
- key.with_lock do
111
- phase_result = phase_callable.call
144
+ staged_job = GlobalID::Locator.locate(staged_job_gid)
145
+ staged_job.delete
146
+ true
147
+ rescue ActiveRecord::RecordNotFound
148
+ true
149
+ end
112
150
 
113
- phase_result.call(key: key)
114
- end
115
- rescue StandardError => e
116
- rescued_error = e
117
- raise e
118
- ensure
119
- if rescued_error
120
- # If we're leaving under an error condition, try to unlock the idempotency
121
- # key right away so that another request can try again.3
122
- begin
123
- key.update_columns(locked_at: nil, error_object: rescued_error)
124
- rescue StandardError => e
125
- # We're already inside an error condition, so swallow any additional
126
- # errors from here and just send them to logs.
127
- puts "Failed to unlock key #{key.id} because of #{e}."
128
- end
151
+ def define_workflow(steps)
152
+ steps << { "does" => Key::RECOVERY_POINT_FINISHED }
153
+
154
+ {}.tap do |workflow|
155
+ steps.each_cons(2).map do |enter_step, exit_step|
156
+ enter_name = enter_step["does"]
157
+ workflow[enter_name] = {
158
+ "then" => exit_step["does"]
159
+ }.merge(enter_step)
129
160
  end
130
161
  end
131
162
  end
132
163
 
133
- def ensure_idempotency_key_record(key_val, first_step)
164
+ def ensure_idempotency_key_record(key_val, workflow, accessors)
134
165
  isolation_level = case ActiveRecord::Base.connection.adapter_name.downcase.to_sym
135
166
  when :sqlite
136
167
  :read_uncommitted
@@ -139,29 +170,67 @@ module AcidicJob
139
170
  end
140
171
 
141
172
  ActiveRecord::Base.transaction(isolation: isolation_level) do
142
- @key = Key.find_by(idempotency_key: key_val)
173
+ key = Key.find_by(idempotency_key: key_val)
143
174
 
144
- if @key
175
+ if key.present?
145
176
  # Programs enqueuing multiple jobs with different parameters but the
146
177
  # same idempotency key is a bug.
147
- raise MismatchedIdempotencyKeyAndJobArguments if @key.job_args != @arguments_for_perform
178
+ raise MismatchedIdempotencyKeyAndJobArguments if key.job_args != @arguments_for_perform
148
179
 
149
180
  # Only acquire a lock if the key is unlocked or its lock has expired
150
181
  # because the original job was long enough ago.
151
- raise LockedIdempotencyKey if @key.locked_at && @key.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
182
+ raise LockedIdempotencyKey if key.locked_at && key.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
152
183
 
153
184
  # Lock the key and update latest run unless the job is already finished.
154
- @key.update!(last_run_at: Time.current, locked_at: Time.current) unless @key.finished?
185
+ key.update!(last_run_at: Time.current, locked_at: Time.current) unless key.finished?
155
186
  else
156
- @key = Key.create!(
187
+ key = Key.create!(
157
188
  idempotency_key: key_val,
158
189
  locked_at: Time.current,
159
190
  last_run_at: Time.current,
160
- recovery_point: first_step,
191
+ recovery_point: workflow.first.first,
161
192
  job_name: self.class.name,
162
- job_args: @arguments_for_perform
193
+ job_args: @arguments_for_perform,
194
+ workflow: workflow
163
195
  )
164
196
  end
197
+
198
+ # set accessors for each argument passed in to ensure they are available
199
+ # to the step methods the job will have written
200
+ define_accessors_for_passed_arguments(accessors, key)
201
+
202
+ # NOTE: we must return the `key` object from this transaction block
203
+ # so that it can be returned from this method to the caller
204
+ key
205
+ end
206
+ end
207
+
208
+ def acidic_step(key, step)
209
+ rescued_error = false
210
+ step_callable = wrap_step_as_acidic_callable step
211
+
212
+ begin
213
+ key.with_lock do
214
+ step_result = step_callable.call(key)
215
+
216
+ step_result.call(key: key)
217
+ end
218
+ # QUESTION: Can an error not inherit from StandardError
219
+ rescue StandardError => e
220
+ rescued_error = e
221
+ raise e
222
+ ensure
223
+ if rescued_error
224
+ # If we're leaving under an error condition, try to unlock the idempotency
225
+ # key right away so that another request can try again.3
226
+ begin
227
+ key.update_columns(locked_at: nil, error_object: rescued_error)
228
+ rescue StandardError => e
229
+ # We're already inside an error condition, so swallow any additional
230
+ # errors from here and just send them to logs.
231
+ puts "Failed to unlock key #{key.id} because of #{e}."
232
+ end
233
+ end
165
234
  end
166
235
  end
167
236
 
@@ -191,20 +260,64 @@ module AcidicJob
191
260
  true
192
261
  end
193
262
 
194
- def define_atomic_phases(defined_steps)
195
- defined_steps << Key::RECOVERY_POINT_FINISHED
196
-
197
- {}.tap do |phases|
198
- defined_steps.each_cons(2).map do |enter_method, exit_method|
199
- phases[enter_method] = lambda do
200
- result = method(enter_method).call
201
-
202
- if result.is_a?(Response)
203
- result
204
- elsif exit_method.to_s == Key::RECOVERY_POINT_FINISHED
205
- Response.new
263
+ # rubocop:disable Metrics/PerceivedComplexity
264
+ def wrap_step_as_acidic_callable(step)
265
+ # {:then=>:next_step, :does=>:enqueue_step, :awaits=>[WorkerWithEnqueueStep::FirstWorker]}
266
+ current_step = step["does"]
267
+ next_step = step["then"]
268
+
269
+ callable = if respond_to? current_step, _include_private = true
270
+ method(current_step)
271
+ else
272
+ proc {} # no-op
273
+ end
274
+
275
+ proc do |key|
276
+ result = if callable.arity.zero?
277
+ callable.call
278
+ elsif callable.arity == 1
279
+ callable.call(key)
280
+ else
281
+ raise TooManyParametersForStepMethod
282
+ end
283
+
284
+ if result.is_a?(Response)
285
+ result
286
+ elsif next_step.to_s == Key::RECOVERY_POINT_FINISHED
287
+ Response.new
288
+ else
289
+ RecoveryPoint.new(next_step)
290
+ end
291
+ end
292
+ end
293
+ # rubocop:enable Metrics/PerceivedComplexity
294
+
295
+ def enqueue_step_parallel_jobs(jobs)
296
+ # TODO: GIVE PROPER ERROR
297
+ # `batch` is available from Sidekiq::Pro
298
+ raise SidekiqBatchRequired unless defined?(Sidekiq::Batch)
299
+
300
+ batch.jobs do
301
+ step_batch = Sidekiq::Batch.new
302
+ # step_batch.description = "AcidicJob::Workflow Step: #{step}"
303
+ step_batch.on(
304
+ :success,
305
+ "#{self.class.name}#step_done",
306
+ # NOTE: options are marshalled through JSON so use only basic types.
307
+ { "key_id" => @key.id }
308
+ )
309
+ # NOTE: The jobs method is atomic.
310
+ # All jobs created in the block are actually pushed atomically at the end of the block.
311
+ # If an error is raised, none of the jobs will go to Redis.
312
+ step_batch.jobs do
313
+ jobs.each do |worker_name|
314
+ worker = worker_name.is_a?(String) ? worker_name.constantize : worker_name
315
+ if worker.instance_method(:perform).arity.zero?
316
+ worker.perform_async
317
+ elsif worker.instance_method(:perform).arity == 1
318
+ worker.perform_async(key.id)
206
319
  else
207
- RecoveryPoint.new(exit_method)
320
+ raise TooManyParametersForParallelJob
208
321
  end
209
322
  end
210
323
  end
@@ -215,8 +328,13 @@ module AcidicJob
215
328
  return job_id if defined?(job_id) && !job_id.nil?
216
329
  return jid if defined?(jid) && !jid.nil?
217
330
 
218
- require "securerandom"
219
- SecureRandom.hex
331
+ Digest::SHA1.hexdigest [self.class.name, arguments_for_perform].flatten.join
332
+ end
333
+
334
+ def step_done(_status, options)
335
+ key = Key.find(options["key_id"])
336
+ # when a batch of jobs for a step succeeds, we begin the key processing again
337
+ process_key(key)
220
338
  end
221
339
  end
222
340
  # rubocop:enable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
@@ -9,6 +9,7 @@ class CreateAcidicJobKeys < <%= migration_class %>
9
9
  t.string :recovery_point, null: false
10
10
  t.text :error_object
11
11
  t.text :attr_accessors
12
+ t.text :workflow
12
13
  t.timestamps
13
14
 
14
15
  t.index %i[idempotency_key job_name job_args],
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: 0.5.5
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - fractaledmind
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-10-13 00:00:00.000000000 Z
11
+ date: 2021-11-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -73,6 +73,7 @@ files:
73
73
  - bin/setup
74
74
  - blog_post.md
75
75
  - lib/acidic_job.rb
76
+ - lib/acidic_job/deliver_transactionally_extension.rb
76
77
  - lib/acidic_job/errors.rb
77
78
  - lib/acidic_job/key.rb
78
79
  - lib/acidic_job/no_op.rb
@@ -80,12 +81,12 @@ files:
80
81
  - lib/acidic_job/perform_wrapper.rb
81
82
  - lib/acidic_job/recovery_point.rb
82
83
  - lib/acidic_job/response.rb
84
+ - lib/acidic_job/sidekiq_callbacks.rb
83
85
  - lib/acidic_job/staged.rb
84
86
  - lib/acidic_job/version.rb
85
87
  - lib/generators/acidic_job_generator.rb
86
88
  - lib/generators/templates/create_acidic_job_keys_migration.rb.erb
87
89
  - lib/generators/templates/create_staged_acidic_jobs_migration.rb.erb
88
- - slides.md
89
90
  homepage: https://github.com/fractaledmind/acidic_job
90
91
  licenses:
91
92
  - MIT
@@ -108,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
109
  - !ruby/object:Gem::Version
109
110
  version: '0'
110
111
  requirements: []
111
- rubygems_version: 3.1.2
112
+ rubygems_version: 3.2.22
112
113
  signing_key:
113
114
  specification_version: 4
114
115
  summary: Idempotent operations for Rails apps, built on top of ActiveJob.
data/slides.md DELETED
@@ -1,65 +0,0 @@
1
- # ACIDic Jobs
2
-
3
- ## A bit about me
4
-
5
- - programming in Ruby for 6 years
6
- - working for test IO / EPAM
7
- - consulting for RCRDSHP
8
- - building Smokestack QA on the side
9
-
10
- ## Jobs are essential
11
-
12
- - job / operation / work
13
- - in every company, with every app, jobs are essential. Why?
14
- - jobs are what your app *does*, expressed as a distinct unit
15
- - jobs can be called from anywhere, run sync or async, and have retry mechanisms built-in
16
-
17
- ## Jobs are internal API endpoints
18
-
19
- - Like API endpoints, both are discrete units of work
20
- - Like API endpoints, we should expect failure
21
- - Like API endpoints, we should expect retries
22
- - Like API endpoints, we should expect concurrency
23
- - this symmetry allows us to port much of the wisdom built up over decades of building robust APIs to our app job infrastructure
24
-
25
- ## ACIDic APIs
26
-
27
- In a loosely collected series of articles, Brandur Leach lays out the core techniques and principles required to make an HTTP API properly ACIDic:
28
-
29
- 1. https://brandur.org/acid
30
- 2. https://brandur.org/http-transactions
31
- 3. https://brandur.org/job-drain
32
- 4. https://brandur.org/idempotency-keys
33
-
34
- His central points can be summarized as follows:
35
-
36
- - "ACID databases are one of the most important tools in existence for ensuring maintainability and data correctness in big production systems"
37
- - "for a common idempotent HTTP request, requests should map to backend transactions at 1:1"
38
- - "We can dequeue jobs gracefully by using a transactionally-staged job drain."
39
- - "Implementations that need to make synchronous changes in foreign state (i.e. outside of a local ACID store) are somewhat more difficult to design. ... To guarantee idempotency on this type of endpoint we’ll need to introduce idempotency keys."
40
-
41
- Key concepts:
42
-
43
- - foreign state mutations
44
- - The reason that the local vs. foreign distinction matters is that unlike a local set of operations where we can leverage an ACID store to roll back a result that we didn’t like, once we make our first foreign state mutation, we’re committed one way or another
45
- - "An atomic phase is a set of local state mutations that occur in transactions between foreign state mutations."
46
- - "A recovery point is a name of a check point that we get to after having successfully executed any atomic phase or foreign state mutation"
47
- - "transactionally-staged job drain"
48
- - "With this pattern, jobs aren’t immediately sent to the job queue. Instead, they’re staged in a table within the relational database itself, and the ACID properties of the running transaction keep them invisible until they’re ready to be worked. A secondary enqueuer process reads the table and sends any jobs it finds to the job queue before removing their rows."
49
-
50
-
51
- https://github.com/mperham/sidekiq/wiki/Best-Practices#2-make-your-job-idempotent-and-transactional
52
-
53
- 2. Make your job idempotent and transactional
54
-
55
- Idempotency means that your job can safely execute multiple times. For instance, with the error retry functionality, your job might be half-processed, throw an error, and then be re-executed over and over until it successfully completes. Let's say you have a job which voids a credit card transaction and emails the user to let them know the charge has been refunded:
56
-
57
- ```ruby
58
- def perform(card_charge_id)
59
- charge = CardCharge.find(card_charge_id)
60
- charge.void_transaction
61
- Emailer.charge_refunded(charge).deliver
62
- end
63
- ```
64
-
65
- What happens when the email fails to render due to a bug? Will the void_transaction method handle the case where a charge has already been refunded? You can use a database transaction to ensure data changes are rolled back if there is an error or you can write your code to be resilient in the face of errors. Just remember that Sidekiq will execute your job at least once, not exactly once.