acidic_job 0.6.0 → 0.7.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: 739d9a393b9dfcf91c6e941e188cab965a2800cf7a729e70a5d30542c76b1237
4
- data.tar.gz: 6fb81e32e31e6db7001a11d607f33d3bc7f1cb5ddc147d48f8ead84ae15fc7d1
3
+ metadata.gz: f6f61f9992e6528f4e7c1b6a5b774cd1fa1557b4f069fbd14b5e415298dbde15
4
+ data.tar.gz: 46e279aca2bdc5e2379b496374334f0687dff92c845c2c4725d7e7fa7b96b571
5
5
  SHA512:
6
- metadata.gz: bc2d1796950ffb4640d49dc61e05771b2a72f6b6776d96dda4fa35f93720e0687102e08bc8ad39a176d44be828528048df03c4492cbd0b1d121865ef929e5b8f
7
- data.tar.gz: 32db6d94ca8a8a70d48a7afa863f85f50ebdf05320c9461b1f8c647d4681e697d7d5bc814864fb8846b516482db7057e8659598387f661e43764115f1525e840
6
+ metadata.gz: 8042feab92bccc37ad4006145c7c1c780da1eb94cf24f11dfc6808ce95edc18c986ca2424c4aa9c468444267da21ff103089ad0a7e9577a7e6f3e32d097daba7
7
+ data.tar.gz: fc7ea7a48fc6560c3984538d9c8d2dd681492f22bd60bd4104d4db39a1bed53b950a386384f4db43caf22bc72ab0c3c9b39c7e36eb4232f646ee6e1ef7885430
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.6.0)
4
+ acidic_job (0.7.0)
5
5
  activerecord (>= 4.0.0)
6
6
  activesupport
7
7
 
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,75 @@ 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.
79
159
 
80
160
  ## Development
81
161
 
@@ -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,6 @@ module AcidicJob
14
14
  class SerializedTransactionConflict < Error; end
15
15
 
16
16
  class UnknownJobAdapter < Error; end
17
+
18
+ class NoDefinedSteps < Error; end
17
19
  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] }
@@ -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.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/acidic_job.rb CHANGED
@@ -9,6 +9,7 @@ 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/sidekiq_callbacks"
12
13
  require "active_support/concern"
13
14
 
14
15
  # rubocop:disable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
@@ -17,13 +18,22 @@ module AcidicJob
17
18
 
18
19
  def self.wire_everything_up(klass)
19
20
  klass.attr_reader :key
20
- klass.attr_accessor :arguments_for_perform
21
+ klass.attr_reader :staged_job_gid
22
+ klass.attr_reader :arguments_for_perform
21
23
 
22
24
  # Extend ActiveJob with `perform_transactionally` class method
23
25
  klass.include PerformTransactionallyExtension
24
26
 
27
+ if defined?(ActionMailer)
28
+ ActionMailer::Parameterized::MessageDelivery.include DeliverTransactionallyExtension
29
+ end
30
+
25
31
  # Ensure our `perform` method always runs first to gather parameters
26
32
  klass.prepend PerformWrapper
33
+
34
+ klass.prepend SidekiqCallbacks unless klass.respond_to?(:after_perform)
35
+
36
+ klass.after_perform :delete_staged_job_record, if: :staged_job_gid
27
37
  end
28
38
 
29
39
  included do
@@ -35,6 +45,14 @@ module AcidicJob
35
45
  AcidicJob.wire_everything_up(subclass)
36
46
  super
37
47
  end
48
+
49
+ def initiate(*args)
50
+ operation = Sidekiq::Batch.new
51
+ operation.on(:success, self, *args)
52
+ operation.jobs do
53
+ perform_async
54
+ end
55
+ end
38
56
  end
39
57
 
40
58
  # Number of seconds passed which we consider a held idempotency key lock to be
@@ -45,43 +63,54 @@ module AcidicJob
45
63
 
46
64
  # takes a block
47
65
  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]
66
+ # execute the block to gather the info on what steps are defined for this job workflow
67
+ steps = yield || []
51
68
 
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 >, ... }
69
+ raise NoDefinedSteps if steps.empty?
55
70
 
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
71
+ # convert the array of steps into a hash of recovery_points and next steps
72
+ workflow = define_workflow(steps)
73
+
74
+ # find or create a Key record (our idempotency key) to store all information about this job
58
75
  #
59
76
  # A key concept here is that if two requests try to insert or update within
60
77
  # close proximity, one of the two will be aborted by Postgres because we're
61
78
  # using a transaction with SERIALIZABLE isolation level. It may not look
62
79
  # it, but this code is safe from races.
63
- ensure_idempotency_key_record(idempotency_key_value, defined_steps.first)
80
+ key = ensure_idempotency_key_record(idempotency_key_value, workflow, with)
81
+
82
+ # begin the workflow
83
+ process_key(key)
84
+ end
85
+
86
+ def process_key(key)
87
+ @key = key
64
88
 
65
89
  # if the key record is already marked as finished, immediately return its result
66
90
  return @key.succeeded? if @key.finished?
67
91
 
68
- # set accessors for each argument passed in to ensure they are available
69
- # to the step methods the job will have written
70
- # THIS HAPPENS OUTSIDE OF ANY TRANSACTION
71
- define_accessors_for_passed_arguments(with, @key)
72
-
73
- # otherwise, we will enter a loop to process each required step of the job
74
- phases.size.times do
75
- # our `phases` hash uses Symbols for keys
76
- recovery_point = @key.recovery_point.to_sym
92
+ # otherwise, we will enter a loop to process each step of the workflow
93
+ @key.workflow.size.times do
94
+ recovery_point = @key.recovery_point.to_s
95
+ current_step = @key.workflow[recovery_point]
77
96
 
78
- case recovery_point
79
- when Key::RECOVERY_POINT_FINISHED.to_sym
97
+ if recovery_point == Key::RECOVERY_POINT_FINISHED.to_s
80
98
  break
99
+ elsif current_step.nil?
100
+ raise UnknownRecoveryPoint, "Defined workflow does not reference this step: #{recovery_point}"
101
+ elsif (jobs = current_step.fetch("awaits", [])).any?
102
+ acidic_step @key, current_step
103
+ # THIS MUST BE DONE AFTER THE KEY RECOVERY POINT HAS BEEN UPDATED
104
+ enqueue_step_parallel_jobs(jobs)
105
+ # after processing the current step, break the processing loop
106
+ # and stop this method from blocking in the primary worker
107
+ # as it will continue once the background workers all succeed
108
+ # so we want to keep the primary worker queue free to process new work
109
+ # this CANNOT ever be `break` as that wouldn't exit the parent job,
110
+ # only this step in the workflow, blocking as it awaits the next step
111
+ return true
81
112
  else
82
- raise UnknownRecoveryPoint unless phases.key? recovery_point
83
-
84
- atomic_phase @key, phases[recovery_point]
113
+ acidic_step @key, current_step
85
114
  end
86
115
  end
87
116
 
@@ -89,9 +118,14 @@ module AcidicJob
89
118
  @key.succeeded?
90
119
  end
91
120
 
92
- def step(method_name)
121
+ def step(method_name, awaits: [])
93
122
  @_steps ||= []
94
- @_steps << method_name
123
+
124
+ @_steps << {
125
+ "does" => method_name.to_s,
126
+ "awaits" => awaits
127
+ }
128
+
95
129
  @_steps
96
130
  end
97
131
 
@@ -103,35 +137,30 @@ module AcidicJob
103
137
 
104
138
  private
105
139
 
106
- def atomic_phase(key, proc = nil, &block)
107
- rescued_error = false
108
- phase_callable = (proc || block)
140
+ def delete_staged_job_record
141
+ return unless staged_job_gid
109
142
 
110
- begin
111
- key.with_lock do
112
- phase_result = phase_callable.call
143
+ staged_job = GlobalID::Locator.locate(staged_job_gid)
144
+ staged_job.delete
145
+ true
146
+ rescue ActiveRecord::RecordNotFound
147
+ true
148
+ end
113
149
 
114
- phase_result.call(key: key)
115
- end
116
- rescue StandardError => e
117
- rescued_error = e
118
- raise e
119
- ensure
120
- if rescued_error
121
- # If we're leaving under an error condition, try to unlock the idempotency
122
- # key right away so that another request can try again.3
123
- begin
124
- key.update_columns(locked_at: nil, error_object: rescued_error)
125
- rescue StandardError => e
126
- # We're already inside an error condition, so swallow any additional
127
- # errors from here and just send them to logs.
128
- puts "Failed to unlock key #{key.id} because of #{e}."
129
- end
150
+ def define_workflow(steps)
151
+ steps << { "does" => Key::RECOVERY_POINT_FINISHED }
152
+
153
+ {}.tap do |workflow|
154
+ steps.each_cons(2).map do |enter_step, exit_step|
155
+ enter_name = enter_step["does"]
156
+ workflow[enter_name] = {
157
+ "then" => exit_step["does"]
158
+ }.merge(enter_step)
130
159
  end
131
160
  end
132
161
  end
133
162
 
134
- def ensure_idempotency_key_record(key_val, first_step)
163
+ def ensure_idempotency_key_record(key_val, workflow, accessors)
135
164
  isolation_level = case ActiveRecord::Base.connection.adapter_name.downcase.to_sym
136
165
  when :sqlite
137
166
  :read_uncommitted
@@ -140,29 +169,67 @@ module AcidicJob
140
169
  end
141
170
 
142
171
  ActiveRecord::Base.transaction(isolation: isolation_level) do
143
- @key = Key.find_by(idempotency_key: key_val)
172
+ key = Key.find_by(idempotency_key: key_val)
144
173
 
145
- if @key.present?
174
+ if key.present?
146
175
  # Programs enqueuing multiple jobs with different parameters but the
147
176
  # same idempotency key is a bug.
148
- raise MismatchedIdempotencyKeyAndJobArguments if @key.job_args != @arguments_for_perform
177
+ raise MismatchedIdempotencyKeyAndJobArguments if key.job_args != @arguments_for_perform
149
178
 
150
179
  # Only acquire a lock if the key is unlocked or its lock has expired
151
180
  # because the original job was long enough ago.
152
- raise LockedIdempotencyKey if @key.locked_at && @key.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
181
+ raise LockedIdempotencyKey if key.locked_at && key.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
153
182
 
154
183
  # Lock the key and update latest run unless the job is already finished.
155
- @key.update!(last_run_at: Time.current, locked_at: Time.current) unless @key.finished?
184
+ key.update!(last_run_at: Time.current, locked_at: Time.current) unless key.finished?
156
185
  else
157
- @key = Key.create!(
186
+ key = Key.create!(
158
187
  idempotency_key: key_val,
159
188
  locked_at: Time.current,
160
189
  last_run_at: Time.current,
161
- recovery_point: first_step,
190
+ recovery_point: workflow.first.first,
162
191
  job_name: self.class.name,
163
- job_args: @arguments_for_perform
192
+ job_args: @arguments_for_perform,
193
+ workflow: workflow
164
194
  )
165
195
  end
196
+
197
+ # set accessors for each argument passed in to ensure they are available
198
+ # to the step methods the job will have written
199
+ define_accessors_for_passed_arguments(accessors, key)
200
+
201
+ # NOTE: we must return the `key` object from this transaction block
202
+ # so that it can be returned from this method to the caller
203
+ key
204
+ end
205
+ end
206
+
207
+ def acidic_step(key, step)
208
+ rescued_error = false
209
+ step_callable = wrap_step_as_acidic_callable step
210
+
211
+ begin
212
+ key.with_lock do
213
+ step_result = step_callable.call(key)
214
+
215
+ step_result.call(key: key)
216
+ end
217
+ # QUESTION: Can an error not inherit from StandardError
218
+ rescue StandardError => e
219
+ rescued_error = e
220
+ raise e
221
+ ensure
222
+ if rescued_error
223
+ # If we're leaving under an error condition, try to unlock the idempotency
224
+ # key right away so that another request can try again.3
225
+ begin
226
+ key.update_columns(locked_at: nil, error_object: rescued_error)
227
+ rescue StandardError => e
228
+ # We're already inside an error condition, so swallow any additional
229
+ # errors from here and just send them to logs.
230
+ puts "Failed to unlock key #{key.id} because of #{e}."
231
+ end
232
+ end
166
233
  end
167
234
  end
168
235
 
@@ -192,20 +259,65 @@ module AcidicJob
192
259
  true
193
260
  end
194
261
 
195
- def define_atomic_phases(defined_steps)
196
- defined_steps << Key::RECOVERY_POINT_FINISHED
197
-
198
- {}.tap do |phases|
199
- defined_steps.each_cons(2).map do |enter_method, exit_method|
200
- phases[enter_method] = lambda do
201
- result = method(enter_method).call
202
-
203
- if result.is_a?(Response)
204
- result
205
- elsif exit_method.to_s == Key::RECOVERY_POINT_FINISHED
206
- Response.new
262
+ # rubocop:disable Metrics/PerceivedComplexity
263
+ def wrap_step_as_acidic_callable(step)
264
+ # {:then=>:next_step, :does=>:enqueue_step, :awaits=>[WorkerWithEnqueueStep::FirstWorker]}
265
+ current_step = step["does"]
266
+ next_step = step["then"]
267
+
268
+ callable = if respond_to? current_step, _include_private = true
269
+ method(current_step)
270
+ else
271
+ proc {} # no-op
272
+ end
273
+
274
+ proc do |key|
275
+ result = if callable.arity.zero?
276
+ callable.call
277
+ elsif callable.arity == 1
278
+ callable.call(key)
279
+ else
280
+ # TODO
281
+ raise
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 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)
207
319
  else
208
- RecoveryPoint.new(exit_method)
320
+ raise
209
321
  end
210
322
  end
211
323
  end
@@ -218,5 +330,11 @@ module AcidicJob
218
330
 
219
331
  Digest::SHA1.hexdigest [self.class.name, arguments_for_perform].flatten.join
220
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)
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.6.0
4
+ version: 0.7.0
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-19 00:00:00.000000000 Z
11
+ date: 2021-10-29 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
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.