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 +4 -4
- data/.gitignore +1 -0
- data/Gemfile.lock +1 -1
- data/README.md +85 -5
- data/lib/acidic_job/deliver_transactionally_extension.rb +24 -0
- data/lib/acidic_job/errors.rb +2 -0
- data/lib/acidic_job/key.rb +1 -0
- data/lib/acidic_job/perform_wrapper.rb +18 -2
- data/lib/acidic_job/sidekiq_callbacks.rb +45 -0
- data/lib/acidic_job/staged.rb +21 -3
- data/lib/acidic_job/version.rb +1 -1
- data/lib/acidic_job.rb +188 -70
- data/lib/generators/templates/create_acidic_job_keys_migration.rb.erb +1 -0
- metadata +4 -3
- data/slides.md +0 -65
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f6f61f9992e6528f4e7c1b6a5b774cd1fa1557b4f069fbd14b5e415298dbde15
|
4
|
+
data.tar.gz: 46e279aca2bdc5e2379b496374334f0687dff92c845c2c4725d7e7fa7b96b571
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8042feab92bccc37ad4006145c7c1c780da1eb94cf24f11dfc6808ce95edc18c986ca2424c4aa9c468444267da21ff103089ad0a7e9577a7e6f3e32d097daba7
|
7
|
+
data.tar.gz: fc7ea7a48fc6560c3984538d9c8d2dd681492f22bd60bd4104d4db39a1bed53b950a386384f4db43caf22bc72ab0c3c9b39c7e36eb4232f646ee6e1ef7885430
|
data/.gitignore
CHANGED
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
# AcidicJob
|
2
2
|
|
3
|
-
### Idempotent operations for Rails apps
|
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
|
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
|
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
|
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
|
-
|
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
|
data/lib/acidic_job/errors.rb
CHANGED
data/lib/acidic_job/key.rb
CHANGED
@@ -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
|
data/lib/acidic_job/staged.rb
CHANGED
@@ -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(
|
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
|
-
#
|
29
|
-
|
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
|
data/lib/acidic_job/version.rb
CHANGED
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.
|
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
|
49
|
-
|
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
|
-
|
53
|
-
phases = define_atomic_phases(defined_steps)
|
54
|
-
# { create_ride_and_audit_record: <#Method >, ... }
|
69
|
+
raise NoDefinedSteps if steps.empty?
|
55
70
|
|
56
|
-
#
|
57
|
-
|
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,
|
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
|
-
#
|
69
|
-
|
70
|
-
|
71
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
107
|
-
|
108
|
-
phase_callable = (proc || block)
|
140
|
+
def delete_staged_job_record
|
141
|
+
return unless staged_job_gid
|
109
142
|
|
110
|
-
|
111
|
-
|
112
|
-
|
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
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
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,
|
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
|
-
|
172
|
+
key = Key.find_by(idempotency_key: key_val)
|
144
173
|
|
145
|
-
if
|
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
|
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
|
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
|
-
|
184
|
+
key.update!(last_run_at: Time.current, locked_at: Time.current) unless key.finished?
|
156
185
|
else
|
157
|
-
|
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:
|
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
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
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
|
-
|
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
|
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.
|
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-
|
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.
|