acidic_job 0.3.1 → 0.5.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +8 -1
- data/acidic_job.gemspec +1 -1
- data/blog_post.md +28 -0
- data/lib/acidic_job/errors.rb +15 -0
- data/lib/acidic_job/key.rb +31 -0
- data/lib/acidic_job/perform_transactionally_extension.rb +31 -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 +31 -0
- data/lib/acidic_job/version.rb +1 -1
- data/lib/acidic_job.rb +57 -59
- data/lib/generators/acidic_job_generator.rb +13 -7
- data/lib/generators/templates/{migration.rb → create_acidic_job_keys_migration.rb.erb} +0 -0
- data/lib/generators/templates/create_staged_acidic_jobs_migration.rb.erb +9 -0
- data/slides.md +65 -0
- metadata +17 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7b6e76d892b0953452781a38155801969acf50d81897fb78645b561280620ddc
|
4
|
+
data.tar.gz: 6640c17792135307d21d20f05b49136512368ff84ed748fb2b1ad98d552fc8fb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c489d047845c6fcd27482dfe1a3c0cae89a326cd3c81d4816ca9758c7db9e0edd09d954e719eedcc0d8b2a689137e21486482571bd05b7518af795152fc4bc61
|
7
|
+
data.tar.gz: ddc07f0514be3ad3f8fdec4ffd4bf350a039a4ad95f6b28984f1cb73f29d51ed3511df94eb628f8f336b81d4edae3d9b1c9af3a8b55ab5ac08efc015775c9249
|
data/.rubocop.yml
CHANGED
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.2)
|
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/acidic_job.gemspec
CHANGED
@@ -27,8 +27,8 @@ Gem::Specification.new do |spec|
|
|
27
27
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
28
28
|
spec.require_paths = ["lib"]
|
29
29
|
|
30
|
-
spec.add_dependency "activesupport"
|
31
30
|
spec.add_dependency "activerecord", ">= 4.0.0"
|
31
|
+
spec.add_dependency "activesupport"
|
32
32
|
spec.add_development_dependency "railties", ">= 4.0"
|
33
33
|
|
34
34
|
# For more information and examples about making a new gem, checkout our
|
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,15 @@
|
|
1
|
+
module AcidicJob
|
2
|
+
class Error < StandardError; end
|
3
|
+
|
4
|
+
class MismatchedIdempotencyKeyAndJobArguments < Error; end
|
5
|
+
|
6
|
+
class LockedIdempotencyKey < Error; end
|
7
|
+
|
8
|
+
class UnknownRecoveryPoint < Error; end
|
9
|
+
|
10
|
+
class UnknownAtomicPhaseType < Error; end
|
11
|
+
|
12
|
+
class SerializedTransactionConflict < Error; end
|
13
|
+
|
14
|
+
class UnknownJobAdapter < Error; end
|
15
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record"
|
4
|
+
|
5
|
+
module AcidicJob
|
6
|
+
class Key < ActiveRecord::Base
|
7
|
+
RECOVERY_POINT_FINISHED = "FINISHED"
|
8
|
+
|
9
|
+
self.table_name = "acidic_job_keys"
|
10
|
+
|
11
|
+
serialize :error_object
|
12
|
+
serialize :job_args
|
13
|
+
|
14
|
+
validates :idempotency_key, presence: true, uniqueness: { scope: %i[job_name job_args] }
|
15
|
+
validates :job_name, presence: true
|
16
|
+
validates :last_run_at, presence: true
|
17
|
+
validates :recovery_point, presence: true
|
18
|
+
|
19
|
+
def finished?
|
20
|
+
recovery_point == RECOVERY_POINT_FINISHED
|
21
|
+
end
|
22
|
+
|
23
|
+
def succeeded?
|
24
|
+
finished? && !failed?
|
25
|
+
end
|
26
|
+
|
27
|
+
def failed?
|
28
|
+
error_object.present?
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,31 @@
|
|
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
|
+
def perform_transactionally(*args)
|
11
|
+
attributes = if self < ActiveJob::Base
|
12
|
+
{
|
13
|
+
adapter: "activejob",
|
14
|
+
job_name: self.name,
|
15
|
+
job_args: job_or_instantiate(*args).serialize
|
16
|
+
}
|
17
|
+
elsif self.include? Sidekiq::Worker
|
18
|
+
{
|
19
|
+
adapter: "sidekiq",
|
20
|
+
job_name: self.name,
|
21
|
+
job_args: args
|
22
|
+
}
|
23
|
+
else
|
24
|
+
raise UnknownJobAdapter
|
25
|
+
end
|
26
|
+
|
27
|
+
AcidicJob::Staged.create!(attributes)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
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,31 @@
|
|
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
|
+
if adapter == "activejob"
|
19
|
+
job = ActiveJob::Base.deserialize(job_args)
|
20
|
+
job.enqueue
|
21
|
+
elsif adapter == "sidekiq"
|
22
|
+
Sidekiq::Client.push("class" => job_name, "args" => job_args)
|
23
|
+
else
|
24
|
+
raise UnknownJobAdapter.new(adapter: adapter)
|
25
|
+
end
|
26
|
+
|
27
|
+
# TODO: ensure successful enqueuing before deletion
|
28
|
+
delete
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/acidic_job/version.rb
CHANGED
data/lib/acidic_job.rb
CHANGED
@@ -1,60 +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"
|
8
|
+
require_relative "acidic_job/key"
|
9
|
+
require_relative "acidic_job/staged"
|
10
|
+
require_relative "acidic_job/perform_wrapper"
|
11
|
+
require_relative "acidic_job/perform_transactionally_extension"
|
7
12
|
require "active_support/concern"
|
8
13
|
|
9
|
-
# rubocop:disable Metrics/ModuleLength,
|
14
|
+
# rubocop:disable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
|
10
15
|
module AcidicJob
|
11
|
-
|
12
|
-
|
13
|
-
class LockedIdempotencyKey < StandardError; end
|
14
|
-
|
15
|
-
class UnknownRecoveryPoint < StandardError; end
|
16
|
-
|
17
|
-
class UnknownAtomicPhaseType < StandardError; end
|
18
|
-
|
19
|
-
class SerializedTransactionConflict < StandardError; end
|
20
|
-
|
21
|
-
class Key < ActiveRecord::Base
|
22
|
-
RECOVERY_POINT_FINISHED = "FINISHED"
|
23
|
-
|
24
|
-
self.table_name = "acidic_job_keys"
|
25
|
-
|
26
|
-
serialize :error_object
|
27
|
-
serialize :job_args
|
28
|
-
|
29
|
-
validates :idempotency_key, presence: true, uniqueness: {scope: [:job_name, :job_args]}
|
30
|
-
validates :job_name, presence: true
|
31
|
-
validates :last_run_at, presence: true
|
32
|
-
validates :recovery_point, presence: true
|
16
|
+
extend ActiveSupport::Concern
|
33
17
|
|
34
|
-
|
35
|
-
|
36
|
-
|
18
|
+
def self.wire_everything_up(klass)
|
19
|
+
klass.attr_reader :key
|
20
|
+
klass.attr_accessor :arguments_for_perform
|
37
21
|
|
38
|
-
|
39
|
-
|
40
|
-
end
|
22
|
+
# Extend ActiveJob with `perform_transactionally` class method
|
23
|
+
klass.include PerformTransactionallyExtension
|
41
24
|
|
42
|
-
|
43
|
-
|
44
|
-
end
|
25
|
+
# Ensure our `perform` method always runs first to gather parameters
|
26
|
+
klass.prepend PerformWrapper
|
45
27
|
end
|
46
28
|
|
47
|
-
extend ActiveSupport::Concern
|
48
|
-
|
49
29
|
included do
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
# retry_on ActiveRecord::SerializationFailure
|
30
|
+
AcidicJob.wire_everything_up(self)
|
31
|
+
end
|
32
|
+
|
33
|
+
class_methods do
|
34
|
+
def inherited(subclass)
|
35
|
+
AcidicJob.wire_everything_up(subclass)
|
36
|
+
end
|
58
37
|
end
|
59
38
|
|
60
39
|
# Number of seconds passed which we consider a held idempotency key lock to be
|
@@ -63,8 +42,8 @@ module AcidicJob
|
|
63
42
|
# this might not happen 100% of the time, so this is a hedge against it.
|
64
43
|
IDEMPOTENCY_KEY_LOCK_TIMEOUT = 90
|
65
44
|
|
66
|
-
#
|
67
|
-
def idempotently(with:)
|
45
|
+
# takes a block
|
46
|
+
def idempotently(with:)
|
68
47
|
# set accessors for each argument passed in to ensure they are available
|
69
48
|
# to the step methods the job will have written
|
70
49
|
define_accessors_for_passed_arguments(with)
|
@@ -84,13 +63,13 @@ module AcidicJob
|
|
84
63
|
# close proximity, one of the two will be aborted by Postgres because we're
|
85
64
|
# using a transaction with SERIALIZABLE isolation level. It may not look
|
86
65
|
# it, but this code is safe from races.
|
87
|
-
ensure_idempotency_key_record(
|
66
|
+
ensure_idempotency_key_record(idempotency_key_value, defined_steps.first)
|
88
67
|
|
89
68
|
# if the key record is already marked as finished, immediately return its result
|
90
69
|
return @key.succeeded? if @key.finished?
|
91
70
|
|
92
71
|
# otherwise, we will enter a loop to process each required step of the job
|
93
|
-
|
72
|
+
phases.size.times do
|
94
73
|
# our `phases` hash uses Symbols for keys
|
95
74
|
recovery_point = @key.recovery_point.to_sym
|
96
75
|
|
@@ -114,10 +93,16 @@ module AcidicJob
|
|
114
93
|
@_steps
|
115
94
|
end
|
116
95
|
|
96
|
+
def safely_finish_acidic_job
|
97
|
+
# Short circuits execution by sending execution right to 'finished'.
|
98
|
+
# So, ends the job "successfully"
|
99
|
+
AcidicJob::Response.new
|
100
|
+
end
|
101
|
+
|
117
102
|
private
|
118
103
|
|
119
104
|
def atomic_phase(key, proc = nil, &block)
|
120
|
-
|
105
|
+
rescued_error = false
|
121
106
|
phase_callable = (proc || block)
|
122
107
|
|
123
108
|
begin
|
@@ -126,15 +111,17 @@ module AcidicJob
|
|
126
111
|
|
127
112
|
phase_result.call(key: key)
|
128
113
|
end
|
129
|
-
rescue => e
|
130
|
-
|
114
|
+
rescue StandardError => e
|
115
|
+
rescued_error = e
|
131
116
|
raise e
|
132
117
|
ensure
|
118
|
+
return unless rescued_error
|
119
|
+
|
133
120
|
# If we're leaving under an error condition, try to unlock the idempotency
|
134
|
-
# key right away so that another request can try again.
|
121
|
+
# key right away so that another request can try again.3
|
135
122
|
begin
|
136
|
-
key.update_columns(locked_at: nil, error_object:
|
137
|
-
rescue => e
|
123
|
+
key.update_columns(locked_at: nil, error_object: rescued_error)
|
124
|
+
rescue StandardError => e
|
138
125
|
# We're already inside an error condition, so swallow any additional
|
139
126
|
# errors from here and just send them to logs.
|
140
127
|
puts "Failed to unlock key #{key.id} because of #{e}."
|
@@ -148,8 +135,7 @@ module AcidicJob
|
|
148
135
|
:read_uncommitted
|
149
136
|
else
|
150
137
|
:serializable
|
151
|
-
|
152
|
-
serialized_job_info = serialize
|
138
|
+
end
|
153
139
|
|
154
140
|
ActiveRecord::Base.transaction(isolation: isolation_level) do
|
155
141
|
@key = Key.find_by(idempotency_key: key_val)
|
@@ -157,11 +143,15 @@ module AcidicJob
|
|
157
143
|
if @key
|
158
144
|
# Programs enqueuing multiple jobs with different parameters but the
|
159
145
|
# same idempotency key is a bug.
|
160
|
-
|
146
|
+
if @key.job_args != @arguments_for_perform
|
147
|
+
raise MismatchedIdempotencyKeyAndJobArguments
|
148
|
+
end
|
161
149
|
|
162
150
|
# Only acquire a lock if the key is unlocked or its lock has expired
|
163
151
|
# because the original job was long enough ago.
|
164
|
-
|
152
|
+
if @key.locked_at && @key.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
|
153
|
+
raise LockedIdempotencyKey
|
154
|
+
end
|
165
155
|
|
166
156
|
# Lock the key and update latest run unless the job is already finished.
|
167
157
|
@key.update!(last_run_at: Time.current, locked_at: Time.current) unless @key.finished?
|
@@ -171,8 +161,8 @@ module AcidicJob
|
|
171
161
|
locked_at: Time.current,
|
172
162
|
last_run_at: Time.current,
|
173
163
|
recovery_point: first_step,
|
174
|
-
job_name:
|
175
|
-
job_args:
|
164
|
+
job_name: self.class.name,
|
165
|
+
job_args: @arguments_for_perform
|
176
166
|
)
|
177
167
|
end
|
178
168
|
end
|
@@ -206,5 +196,13 @@ module AcidicJob
|
|
206
196
|
end
|
207
197
|
end
|
208
198
|
end
|
199
|
+
|
200
|
+
def idempotency_key_value
|
201
|
+
return job_id if defined?(job_id) && !job_id.nil?
|
202
|
+
return jid if defined?(jid) && !jid.nil?
|
203
|
+
|
204
|
+
require 'securerandom'
|
205
|
+
SecureRandom.hex
|
206
|
+
end
|
209
207
|
end
|
210
|
-
# rubocop:enable Metrics/ModuleLength,
|
208
|
+
# rubocop:enable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
|
@@ -1,11 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "rails/generators"
|
2
4
|
require "rails/generators/active_record"
|
3
5
|
|
4
|
-
# This generator adds a migration for the {FriendlyId::History
|
5
|
-
# FriendlyId::History} addon.
|
6
6
|
class AcidicJobGenerator < ActiveRecord::Generators::Base
|
7
|
-
# ActiveRecord::Generators::Base inherits from Rails::Generators::NamedBase
|
8
|
-
#
|
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.
|
9
10
|
argument :name, type: :string, default: "random_name"
|
10
11
|
|
11
12
|
source_root File.expand_path("templates", __dir__)
|
@@ -21,9 +22,14 @@ class AcidicJobGenerator < ActiveRecord::Generators::Base
|
|
21
22
|
end
|
22
23
|
|
23
24
|
# Copies the migration template to db/migrate.
|
24
|
-
def
|
25
|
-
migration_template "
|
26
|
-
|
25
|
+
def copy_acidic_job_keys_migration_files
|
26
|
+
migration_template "create_acidic_job_keys_migration.rb.erb",
|
27
|
+
"db/migrate/create_acidic_job_keys.rb"
|
28
|
+
end
|
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"
|
27
33
|
end
|
28
34
|
|
29
35
|
protected
|
File without changes
|
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,43 +1,43 @@
|
|
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.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- fractaledmind
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-09-
|
11
|
+
date: 2021-09-29 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: activerecord
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
19
|
+
version: 4.0.0
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
26
|
+
version: 4.0.0
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
28
|
+
name: activesupport
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - ">="
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version:
|
33
|
+
version: '0'
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version:
|
40
|
+
version: '0'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
42
|
name: railties
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -71,13 +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
|
77
|
+
- lib/acidic_job/key.rb
|
75
78
|
- lib/acidic_job/no_op.rb
|
79
|
+
- lib/acidic_job/perform_transactionally_extension.rb
|
80
|
+
- lib/acidic_job/perform_wrapper.rb
|
76
81
|
- lib/acidic_job/recovery_point.rb
|
77
82
|
- lib/acidic_job/response.rb
|
83
|
+
- lib/acidic_job/staged.rb
|
78
84
|
- lib/acidic_job/version.rb
|
79
85
|
- lib/generators/acidic_job_generator.rb
|
80
|
-
- 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
|
81
89
|
homepage: https://github.com/fractaledmind/acidic_job
|
82
90
|
licenses:
|
83
91
|
- MIT
|