acidic_job 0.4.0 → 0.5.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +2 -0
- data/Gemfile.lock +8 -1
- data/blog_post.md +28 -0
- data/lib/acidic_job/errors.rb +17 -0
- data/lib/acidic_job/key.rb +3 -0
- data/lib/acidic_job/perform_transactionally_extension.rb +33 -0
- data/lib/acidic_job/perform_wrapper.rb +21 -0
- data/lib/acidic_job/recovery_point.rb +1 -0
- data/lib/acidic_job/response.rb +2 -1
- data/lib/acidic_job/staged.rb +32 -0
- data/lib/acidic_job/version.rb +1 -1
- data/lib/acidic_job.rb +67 -47
- data/lib/generators/acidic_job_generator.rb +10 -6
- data/lib/generators/templates/{migration.rb.erb → create_acidic_job_keys_migration.rb.erb} +1 -3
- data/lib/generators/templates/create_staged_acidic_jobs_migration.rb.erb +9 -0
- data/slides.md +65 -0
- metadata +10 -4
- data/lib/acidic_job/staging.rb +0 -19
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e4041a344bd53943f281696cafdd0b0a7fa05969b33ac849475f39efd9333018
|
4
|
+
data.tar.gz: 73a7b1175df2b8e8b7ce6e89cbc13bce208e575b662a31ea03f11ed66fbd0961
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bb042126f12c372d0d3767d785d095aa68b48bb71faa10866fa7de6216b47ff7e885ef55611b10cd1d65f9d53aff515a7723fc429ee3095dc3b6ac749e0f761d
|
7
|
+
data.tar.gz: 8cbf5b168781b8ea1125382b998492e6f6d38cf1b46dc8c766b432e93f19e786547dc5dfc8f5437b5623d33970d67d090857abdcd58a5eb5e80804fc682efd36
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
acidic_job (0.
|
4
|
+
acidic_job (0.5.3)
|
5
5
|
activerecord (>= 4.0.0)
|
6
6
|
activesupport
|
7
7
|
|
@@ -39,6 +39,7 @@ GEM
|
|
39
39
|
builder (3.2.4)
|
40
40
|
coderay (1.1.3)
|
41
41
|
concurrent-ruby (1.1.9)
|
42
|
+
connection_pool (2.2.5)
|
42
43
|
crass (1.0.6)
|
43
44
|
database_cleaner (2.0.1)
|
44
45
|
database_cleaner-active_record (~> 2.0.0)
|
@@ -86,6 +87,7 @@ GEM
|
|
86
87
|
thor (~> 1.0)
|
87
88
|
rainbow (3.0.0)
|
88
89
|
rake (13.0.4)
|
90
|
+
redis (4.4.0)
|
89
91
|
regexp_parser (2.1.1)
|
90
92
|
rexml (3.2.5)
|
91
93
|
rubocop (1.18.3)
|
@@ -104,6 +106,10 @@ GEM
|
|
104
106
|
rubocop-rake (0.6.0)
|
105
107
|
rubocop (~> 1.0)
|
106
108
|
ruby-progressbar (1.11.0)
|
109
|
+
sidekiq (6.2.2)
|
110
|
+
connection_pool (>= 2.2.2)
|
111
|
+
rack (~> 2.0)
|
112
|
+
redis (>= 4.2.0)
|
107
113
|
simplecov (0.21.2)
|
108
114
|
docile (~> 1.1)
|
109
115
|
simplecov-html (~> 0.11)
|
@@ -133,6 +139,7 @@ DEPENDENCIES
|
|
133
139
|
rubocop (~> 1.7)
|
134
140
|
rubocop-minitest
|
135
141
|
rubocop-rake
|
142
|
+
sidekiq
|
136
143
|
simplecov
|
137
144
|
sqlite3
|
138
145
|
|
data/blog_post.md
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# ACIDic Operations in Rails
|
2
|
+
|
3
|
+
At the conceptual heart of basically any software are "operations"—the discrete actions the software performs. At the horizon of basically any software is the goal to make that sofware _robust_. Typically, one makes a software system robust by making each of its operations robust. Moreover, typically, robustness in software is considered as the software being "ACIDic"—atomic, consistent, isolated, durable.
|
4
|
+
|
5
|
+
In a loosely collected series of articles, Brandur Leach lays out the core techniques and principles required to make an HTTP API properly ACIDic:
|
6
|
+
|
7
|
+
1. https://brandur.org/acid
|
8
|
+
2. https://brandur.org/http-transactions
|
9
|
+
3. https://brandur.org/job-drain
|
10
|
+
4. https://brandur.org/idempotency-keys
|
11
|
+
|
12
|
+
With these techniques and principles in mind, our challenge is bring them into the world of a standard Rails application. This will require us to conceptually map the concepts of an HTTP request, an API server action, and an HTTP response into the world of a running Rails process.
|
13
|
+
|
14
|
+
We can begin to make this mapping by observing that an API server action is a specific instantiation of the general concept of an "operation". Like all operations, it has a "trigger" (the HTTP request) and a "response" (the HTTP response). So, what we need is a foundation upon which to build our Rails "operations".
|
15
|
+
|
16
|
+
In order to help us find that tool, let us consider the necessary characteristics we need. We need something that we can easily trigger from other Ruby code throughout our Rails application (controller actions, model methods, model callbacks, etc.). It should also be able to be run both synchronously (blocking execution and then returning its response to the caller) and asychronously (non-blocking and the caller doesn't know its response). It should then also be able to retry a specific operation (in much the way that an API consumer can "retry an operation" by hitting the same endpoint with the same request).
|
17
|
+
|
18
|
+
As we lay out these characteristics, I imagine your mind is going where mine went—`ActiveJob` gives us a solid foundation upon which we can build "ACIDic" operations.
|
19
|
+
|
20
|
+
So, our challenge to build tooling which will allow us to make "operational" jobs _robust_.
|
21
|
+
|
22
|
+
What we need primarily is to be able to make our jobs *idempotent*, and one of the simplest yet still most powerful tools for making an operation idempotent is the idempotency key. As laid out in the article linked above, an idempotency key is a record that we store in our database to uniquely identify a particular execution of an operation and a related "recovery point" for where we are in the process of that operation.
|
23
|
+
|
24
|
+
|
25
|
+
|
26
|
+
|
27
|
+
|
28
|
+
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AcidicJob
|
4
|
+
class Error < StandardError; end
|
5
|
+
|
6
|
+
class MismatchedIdempotencyKeyAndJobArguments < Error; end
|
7
|
+
|
8
|
+
class LockedIdempotencyKey < Error; end
|
9
|
+
|
10
|
+
class UnknownRecoveryPoint < Error; end
|
11
|
+
|
12
|
+
class UnknownAtomicPhaseType < Error; end
|
13
|
+
|
14
|
+
class SerializedTransactionConflict < Error; end
|
15
|
+
|
16
|
+
class UnknownJobAdapter < Error; end
|
17
|
+
end
|
data/lib/acidic_job/key.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "active_record"
|
4
|
+
|
3
5
|
module AcidicJob
|
4
6
|
class Key < ActiveRecord::Base
|
5
7
|
RECOVERY_POINT_FINISHED = "FINISHED"
|
@@ -8,6 +10,7 @@ module AcidicJob
|
|
8
10
|
|
9
11
|
serialize :error_object
|
10
12
|
serialize :job_args
|
13
|
+
store :attr_accessors
|
11
14
|
|
12
15
|
validates :idempotency_key, presence: true, uniqueness: { scope: %i[job_name job_args] }
|
13
16
|
validates :job_name, presence: true
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
|
5
|
+
module AcidicJob
|
6
|
+
module PerformTransactionallyExtension
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
class_methods do
|
10
|
+
# rubocop:disable Metrics/MethodLength
|
11
|
+
def perform_transactionally(*args)
|
12
|
+
attributes = if self < ActiveJob::Base
|
13
|
+
{
|
14
|
+
adapter: "activejob",
|
15
|
+
job_name: name,
|
16
|
+
job_args: job_or_instantiate(*args).serialize
|
17
|
+
}
|
18
|
+
elsif include? Sidekiq::Worker
|
19
|
+
{
|
20
|
+
adapter: "sidekiq",
|
21
|
+
job_name: name,
|
22
|
+
job_args: args
|
23
|
+
}
|
24
|
+
else
|
25
|
+
raise UnknownJobAdapter
|
26
|
+
end
|
27
|
+
|
28
|
+
AcidicJob::Staged.create!(attributes)
|
29
|
+
end
|
30
|
+
# rubocop:enable Metrics/MethodLength
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AcidicJob
|
4
|
+
module PerformWrapper
|
5
|
+
def perform(*args, **kwargs)
|
6
|
+
# store arguments passed into `perform` so that we can later persist
|
7
|
+
# them to `AcidicJob::Key#job_args` for both ActiveJob and Sidekiq::Worker
|
8
|
+
@arguments_for_perform = if args.any? && kwargs.any?
|
9
|
+
args + [kwargs]
|
10
|
+
elsif args.any? && kwargs.none?
|
11
|
+
args
|
12
|
+
elsif args.none? && kwargs.any?
|
13
|
+
[kwargs]
|
14
|
+
else
|
15
|
+
[]
|
16
|
+
end
|
17
|
+
|
18
|
+
super
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/acidic_job/response.rb
CHANGED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record"
|
4
|
+
|
5
|
+
module AcidicJob
|
6
|
+
class Staged < ActiveRecord::Base
|
7
|
+
self.table_name = "staged_acidic_jobs"
|
8
|
+
|
9
|
+
validates :adapter, presence: true
|
10
|
+
validates :job_name, presence: true
|
11
|
+
validates :job_args, presence: true
|
12
|
+
|
13
|
+
serialize :job_args
|
14
|
+
|
15
|
+
after_create_commit :enqueue_job
|
16
|
+
|
17
|
+
def enqueue_job
|
18
|
+
case adapter
|
19
|
+
when "activejob"
|
20
|
+
job = ActiveJob::Base.deserialize(job_args)
|
21
|
+
job.enqueue
|
22
|
+
when "sidekiq"
|
23
|
+
Sidekiq::Client.push("class" => job_name, "args" => job_args)
|
24
|
+
else
|
25
|
+
raise UnknownJobAdapter.new(adapter: adapter)
|
26
|
+
end
|
27
|
+
|
28
|
+
# TODO: ensure successful enqueuing before deletion
|
29
|
+
delete
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/lib/acidic_job/version.rb
CHANGED
data/lib/acidic_job.rb
CHANGED
@@ -1,45 +1,39 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "acidic_job/version"
|
4
|
+
require_relative "acidic_job/errors"
|
4
5
|
require_relative "acidic_job/no_op"
|
5
6
|
require_relative "acidic_job/recovery_point"
|
6
7
|
require_relative "acidic_job/response"
|
7
8
|
require_relative "acidic_job/key"
|
8
|
-
require_relative "acidic_job/
|
9
|
+
require_relative "acidic_job/staged"
|
10
|
+
require_relative "acidic_job/perform_wrapper"
|
11
|
+
require_relative "acidic_job/perform_transactionally_extension"
|
9
12
|
require "active_support/concern"
|
10
13
|
|
11
14
|
# rubocop:disable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
|
12
15
|
module AcidicJob
|
13
|
-
class MismatchedIdempotencyKeyAndJobArguments < StandardError; end
|
14
|
-
|
15
|
-
class LockedIdempotencyKey < StandardError; end
|
16
|
-
|
17
|
-
class UnknownRecoveryPoint < StandardError; end
|
18
|
-
|
19
|
-
class UnknownAtomicPhaseType < StandardError; end
|
20
|
-
|
21
|
-
class SerializedTransactionConflict < StandardError; end
|
22
|
-
|
23
16
|
extend ActiveSupport::Concern
|
24
17
|
|
25
|
-
|
26
|
-
|
18
|
+
def self.wire_everything_up(klass)
|
19
|
+
klass.attr_reader :key
|
20
|
+
klass.attr_accessor :arguments_for_perform
|
27
21
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
end
|
34
|
-
end
|
22
|
+
# Extend ActiveJob with `perform_transactionally` class method
|
23
|
+
klass.include PerformTransactionallyExtension
|
24
|
+
|
25
|
+
# Ensure our `perform` method always runs first to gather parameters
|
26
|
+
klass.prepend PerformWrapper
|
35
27
|
end
|
36
28
|
|
37
29
|
included do
|
38
|
-
|
30
|
+
AcidicJob.wire_everything_up(self)
|
31
|
+
end
|
39
32
|
|
40
|
-
|
41
|
-
|
42
|
-
|
33
|
+
class_methods do
|
34
|
+
def inherited(subclass)
|
35
|
+
AcidicJob.wire_everything_up(subclass)
|
36
|
+
super
|
43
37
|
end
|
44
38
|
end
|
45
39
|
|
@@ -49,13 +43,8 @@ module AcidicJob
|
|
49
43
|
# this might not happen 100% of the time, so this is a hedge against it.
|
50
44
|
IDEMPOTENCY_KEY_LOCK_TIMEOUT = 90
|
51
45
|
|
52
|
-
#
|
53
|
-
# &block
|
46
|
+
# takes a block
|
54
47
|
def idempotently(with:)
|
55
|
-
# set accessors for each argument passed in to ensure they are available
|
56
|
-
# to the step methods the job will have written
|
57
|
-
define_accessors_for_passed_arguments(with)
|
58
|
-
|
59
48
|
# execute the block to gather the info on what phases are defined for this job
|
60
49
|
defined_steps = yield
|
61
50
|
# [:create_ride_and_audit_record, :create_stripe_charge, :send_receipt]
|
@@ -71,13 +60,17 @@ module AcidicJob
|
|
71
60
|
# close proximity, one of the two will be aborted by Postgres because we're
|
72
61
|
# using a transaction with SERIALIZABLE isolation level. It may not look
|
73
62
|
# it, but this code is safe from races.
|
74
|
-
ensure_idempotency_key_record(
|
63
|
+
ensure_idempotency_key_record(idempotency_key_value, defined_steps.first)
|
75
64
|
|
76
65
|
# if the key record is already marked as finished, immediately return its result
|
77
66
|
return @key.succeeded? if @key.finished?
|
78
67
|
|
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
|
+
|
79
72
|
# otherwise, we will enter a loop to process each required step of the job
|
80
|
-
|
73
|
+
phases.size.times do
|
81
74
|
# our `phases` hash uses Symbols for keys
|
82
75
|
recovery_point = @key.recovery_point.to_sym
|
83
76
|
|
@@ -101,10 +94,16 @@ module AcidicJob
|
|
101
94
|
@_steps
|
102
95
|
end
|
103
96
|
|
97
|
+
def safely_finish_acidic_job
|
98
|
+
# Short circuits execution by sending execution right to 'finished'.
|
99
|
+
# So, ends the job "successfully"
|
100
|
+
AcidicJob::Response.new
|
101
|
+
end
|
102
|
+
|
104
103
|
private
|
105
104
|
|
106
105
|
def atomic_phase(key, proc = nil, &block)
|
107
|
-
|
106
|
+
rescued_error = false
|
108
107
|
phase_callable = (proc || block)
|
109
108
|
|
110
109
|
begin
|
@@ -114,17 +113,19 @@ module AcidicJob
|
|
114
113
|
phase_result.call(key: key)
|
115
114
|
end
|
116
115
|
rescue StandardError => e
|
117
|
-
|
116
|
+
rescued_error = e
|
118
117
|
raise e
|
119
118
|
ensure
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
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
|
128
129
|
end
|
129
130
|
end
|
130
131
|
end
|
@@ -136,7 +137,6 @@ module AcidicJob
|
|
136
137
|
else
|
137
138
|
:serializable
|
138
139
|
end
|
139
|
-
serialized_job_info = serialize
|
140
140
|
|
141
141
|
ActiveRecord::Base.transaction(isolation: isolation_level) do
|
142
142
|
@key = Key.find_by(idempotency_key: key_val)
|
@@ -144,7 +144,7 @@ module AcidicJob
|
|
144
144
|
if @key
|
145
145
|
# Programs enqueuing multiple jobs with different parameters but the
|
146
146
|
# same idempotency key is a bug.
|
147
|
-
raise MismatchedIdempotencyKeyAndJobArguments if @key.job_args !=
|
147
|
+
raise MismatchedIdempotencyKeyAndJobArguments if @key.job_args != @arguments_for_perform
|
148
148
|
|
149
149
|
# Only acquire a lock if the key is unlocked or its lock has expired
|
150
150
|
# because the original job was long enough ago.
|
@@ -158,19 +158,31 @@ module AcidicJob
|
|
158
158
|
locked_at: Time.current,
|
159
159
|
last_run_at: Time.current,
|
160
160
|
recovery_point: first_step,
|
161
|
-
job_name:
|
162
|
-
job_args:
|
161
|
+
job_name: self.class.name,
|
162
|
+
job_args: @arguments_for_perform
|
163
163
|
)
|
164
164
|
end
|
165
165
|
end
|
166
166
|
end
|
167
167
|
|
168
|
-
def define_accessors_for_passed_arguments(passed_arguments)
|
168
|
+
def define_accessors_for_passed_arguments(passed_arguments, key)
|
169
|
+
# first, ensure that `Key#attr_accessors` is populated with initial values
|
170
|
+
key.update_column(:attr_accessors, passed_arguments)
|
171
|
+
|
169
172
|
passed_arguments.each do |accessor, value|
|
170
173
|
# the reader method may already be defined
|
171
174
|
self.class.attr_reader accessor unless respond_to?(accessor)
|
172
175
|
# but we should always update the value to match the current value
|
173
176
|
instance_variable_set("@#{accessor}", value)
|
177
|
+
# and we overwrite the setter to ensure any updates to an accessor update the `Key` stored value
|
178
|
+
# Note: we must define the singleton method on the instance to avoid overwriting setters on other
|
179
|
+
# instances of the same class
|
180
|
+
define_singleton_method("#{accessor}=") do |current_value|
|
181
|
+
instance_variable_set("@#{accessor}", current_value)
|
182
|
+
key.attr_accessors[accessor] = current_value
|
183
|
+
key.save!(validate: false)
|
184
|
+
current_value
|
185
|
+
end
|
174
186
|
end
|
175
187
|
|
176
188
|
true
|
@@ -193,5 +205,13 @@ module AcidicJob
|
|
193
205
|
end
|
194
206
|
end
|
195
207
|
end
|
208
|
+
|
209
|
+
def idempotency_key_value
|
210
|
+
return job_id if defined?(job_id) && !job_id.nil?
|
211
|
+
return jid if defined?(jid) && !jid.nil?
|
212
|
+
|
213
|
+
require "securerandom"
|
214
|
+
SecureRandom.hex
|
215
|
+
end
|
196
216
|
end
|
197
217
|
# rubocop:enable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
|
@@ -3,11 +3,10 @@
|
|
3
3
|
require "rails/generators"
|
4
4
|
require "rails/generators/active_record"
|
5
5
|
|
6
|
-
# This generator adds a migration for the {FriendlyId::History
|
7
|
-
# FriendlyId::History} addon.
|
8
6
|
class AcidicJobGenerator < ActiveRecord::Generators::Base
|
9
|
-
# ActiveRecord::Generators::Base inherits from Rails::Generators::NamedBase
|
10
|
-
#
|
7
|
+
# ActiveRecord::Generators::Base inherits from Rails::Generators::NamedBase
|
8
|
+
# which requires a NAME parameter for the new table name.
|
9
|
+
# Our generator always uses "acidic_job_keys", so we just set a random name here.
|
11
10
|
argument :name, type: :string, default: "random_name"
|
12
11
|
|
13
12
|
source_root File.expand_path("templates", __dir__)
|
@@ -23,11 +22,16 @@ class AcidicJobGenerator < ActiveRecord::Generators::Base
|
|
23
22
|
end
|
24
23
|
|
25
24
|
# Copies the migration template to db/migrate.
|
26
|
-
def
|
27
|
-
migration_template "
|
25
|
+
def copy_acidic_job_keys_migration_files
|
26
|
+
migration_template "create_acidic_job_keys_migration.rb.erb",
|
28
27
|
"db/migrate/create_acidic_job_keys.rb"
|
29
28
|
end
|
30
29
|
|
30
|
+
def copy_staged_acidic_jobs_migration_files
|
31
|
+
migration_template "create_staged_acidic_jobs_migration.rb.erb",
|
32
|
+
"db/migrate/create_staged_acidic_jobs.rb"
|
33
|
+
end
|
34
|
+
|
31
35
|
protected
|
32
36
|
|
33
37
|
def migration_class
|
@@ -8,14 +8,12 @@ class CreateAcidicJobKeys < <%= migration_class %>
|
|
8
8
|
t.datetime :locked_at, null: true
|
9
9
|
t.string :recovery_point, null: false
|
10
10
|
t.text :error_object
|
11
|
+
t.text :attr_accessors
|
11
12
|
t.timestamps
|
12
13
|
|
13
14
|
t.index %i[idempotency_key job_name job_args],
|
14
15
|
unique: true,
|
15
16
|
name: "idx_acidic_job_keys_on_idempotency_key_n_job_name_n_job_args"
|
16
|
-
|
17
|
-
create_table :acidic_job_stagings do |t|
|
18
|
-
t.text :serialized_params, null: false
|
19
17
|
end
|
20
18
|
end
|
21
19
|
end
|
data/slides.md
ADDED
@@ -0,0 +1,65 @@
|
|
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.
|
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.5.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- fractaledmind
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-
|
11
|
+
date: 2021-10-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -71,15 +71,21 @@ files:
|
|
71
71
|
- acidic_job.gemspec
|
72
72
|
- bin/console
|
73
73
|
- bin/setup
|
74
|
+
- blog_post.md
|
74
75
|
- lib/acidic_job.rb
|
76
|
+
- lib/acidic_job/errors.rb
|
75
77
|
- lib/acidic_job/key.rb
|
76
78
|
- lib/acidic_job/no_op.rb
|
79
|
+
- lib/acidic_job/perform_transactionally_extension.rb
|
80
|
+
- lib/acidic_job/perform_wrapper.rb
|
77
81
|
- lib/acidic_job/recovery_point.rb
|
78
82
|
- lib/acidic_job/response.rb
|
79
|
-
- lib/acidic_job/
|
83
|
+
- lib/acidic_job/staged.rb
|
80
84
|
- lib/acidic_job/version.rb
|
81
85
|
- lib/generators/acidic_job_generator.rb
|
82
|
-
- lib/generators/templates/
|
86
|
+
- lib/generators/templates/create_acidic_job_keys_migration.rb.erb
|
87
|
+
- lib/generators/templates/create_staged_acidic_jobs_migration.rb.erb
|
88
|
+
- slides.md
|
83
89
|
homepage: https://github.com/fractaledmind/acidic_job
|
84
90
|
licenses:
|
85
91
|
- MIT
|
data/lib/acidic_job/staging.rb
DELETED
@@ -1,19 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module AcidicJob
|
4
|
-
class Staging < ActiveRecord::Base
|
5
|
-
self.table_name = "acidic_job_stagings"
|
6
|
-
|
7
|
-
validates :serialized_params, presence: true
|
8
|
-
|
9
|
-
serialize :serialized_params
|
10
|
-
|
11
|
-
after_create_commit :enqueue_job
|
12
|
-
|
13
|
-
def enqueue_job
|
14
|
-
job = ActiveJob::Base.deserialize(serialized_params)
|
15
|
-
job.enqueue
|
16
|
-
delete
|
17
|
-
end
|
18
|
-
end
|
19
|
-
end
|