acidic_job 0.2.2 → 0.5.0
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 +4 -0
- data/Gemfile.lock +13 -1
- data/acidic_job.gemspec +11 -11
- 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 +44 -55
- data/lib/generators/acidic_job_generator.rb +19 -12
- data/lib/generators/templates/{migration.rb → create_acidic_job_keys_migration.rb.erb} +4 -3
- 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: 943a9bc87b07b1e4a2ec7d0a83ffa8a23917124386cfc601780f95d36c62d23a
|
4
|
+
data.tar.gz: 2f534e9ebd0d533ae4b7572fb1da1451df9a1a2ed68fc231bc4f4de323de9e84
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 59f4ecc2e773d72bbef2d386a223e9859ee6f14c657e706c593e7a3f4c994c86aae885ef0b2993cbb916a1380daf3231882255685b6ffa360ffc4bf5dee591d8
|
7
|
+
data.tar.gz: 2a8d540fabb8528a9c305b9d7cf7fe12588c14760557f08f4c380dfb6b45fdafec1232767234809101d6aa0e8f44f8ac895b120664dec8e533d180900ad06c6f
|
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.0)
|
5
5
|
activerecord (>= 4.0.0)
|
6
6
|
activesupport
|
7
7
|
|
@@ -37,7 +37,9 @@ GEM
|
|
37
37
|
zeitwerk (~> 2.3)
|
38
38
|
ast (2.4.2)
|
39
39
|
builder (3.2.4)
|
40
|
+
coderay (1.1.3)
|
40
41
|
concurrent-ruby (1.1.9)
|
42
|
+
connection_pool (2.2.5)
|
41
43
|
crass (1.0.6)
|
42
44
|
database_cleaner (2.0.1)
|
43
45
|
database_cleaner-active_record (~> 2.0.0)
|
@@ -65,6 +67,9 @@ GEM
|
|
65
67
|
parallel (1.20.1)
|
66
68
|
parser (3.0.1.1)
|
67
69
|
ast (~> 2.4.1)
|
70
|
+
pry (0.14.1)
|
71
|
+
coderay (~> 1.1)
|
72
|
+
method_source (~> 1.0)
|
68
73
|
racc (1.5.2)
|
69
74
|
rack (2.2.3)
|
70
75
|
rack-test (1.1.0)
|
@@ -82,6 +87,7 @@ GEM
|
|
82
87
|
thor (~> 1.0)
|
83
88
|
rainbow (3.0.0)
|
84
89
|
rake (13.0.4)
|
90
|
+
redis (4.4.0)
|
85
91
|
regexp_parser (2.1.1)
|
86
92
|
rexml (3.2.5)
|
87
93
|
rubocop (1.18.3)
|
@@ -100,6 +106,10 @@ GEM
|
|
100
106
|
rubocop-rake (0.6.0)
|
101
107
|
rubocop (~> 1.0)
|
102
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)
|
103
113
|
simplecov (0.21.2)
|
104
114
|
docile (~> 1.1)
|
105
115
|
simplecov-html (~> 0.11)
|
@@ -123,11 +133,13 @@ DEPENDENCIES
|
|
123
133
|
activerecord (~> 6.1.3.2)
|
124
134
|
database_cleaner
|
125
135
|
minitest (~> 5.0)
|
136
|
+
pry
|
126
137
|
railties (>= 4.0)
|
127
138
|
rake (~> 13.0)
|
128
139
|
rubocop (~> 1.7)
|
129
140
|
rubocop-minitest
|
130
141
|
rubocop-rake
|
142
|
+
sidekiq
|
131
143
|
simplecov
|
132
144
|
sqlite3
|
133
145
|
|
data/acidic_job.gemspec
CHANGED
@@ -3,15 +3,15 @@
|
|
3
3
|
require_relative "lib/acidic_job/version"
|
4
4
|
|
5
5
|
Gem::Specification.new do |spec|
|
6
|
-
spec.name
|
7
|
-
spec.version
|
8
|
-
spec.authors
|
9
|
-
spec.email
|
6
|
+
spec.name = "acidic_job"
|
7
|
+
spec.version = AcidicJob::VERSION
|
8
|
+
spec.authors = ["fractaledmind"]
|
9
|
+
spec.email = ["stephen.margheim@gmail.com"]
|
10
10
|
|
11
|
-
spec.summary
|
12
|
-
spec.description
|
13
|
-
spec.homepage
|
14
|
-
spec.license
|
11
|
+
spec.summary = "Idempotent operations for Rails apps, built on top of ActiveJob."
|
12
|
+
spec.description = "Idempotent operations for Rails apps, built on top of ActiveJob."
|
13
|
+
spec.homepage = "https://github.com/fractaledmind/acidic_job"
|
14
|
+
spec.license = "MIT"
|
15
15
|
spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
|
16
16
|
|
17
17
|
spec.metadata["homepage_uri"] = spec.homepage
|
@@ -23,12 +23,12 @@ Gem::Specification.new do |spec|
|
|
23
23
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
24
24
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
25
25
|
end
|
26
|
-
spec.bindir
|
27
|
-
spec.executables
|
26
|
+
spec.bindir = "exe"
|
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,29 @@
|
|
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
|
-
class MismatchedIdempotencyKeyAndJobArguments < StandardError; end
|
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
|
-
|
28
|
-
validates :job_name, presence: true
|
29
|
-
validates :job_args, presence: true
|
30
|
-
validates :idempotency_key, presence: true
|
31
|
-
validates :last_run_at, presence: true
|
32
|
-
validates :recovery_point, presence: true
|
33
|
-
|
34
|
-
def finished?
|
35
|
-
recovery_point == RECOVERY_POINT_FINISHED
|
36
|
-
end
|
37
|
-
|
38
|
-
def succeeded?
|
39
|
-
finished? && !failed?
|
40
|
-
end
|
41
|
-
|
42
|
-
def failed?
|
43
|
-
error_object.present?
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
16
|
extend ActiveSupport::Concern
|
48
17
|
|
49
18
|
included do
|
50
19
|
attr_reader :key
|
20
|
+
attr_accessor :arguments_for_perform
|
51
21
|
|
52
|
-
#
|
53
|
-
|
54
|
-
|
55
|
-
#
|
56
|
-
|
57
|
-
# retry_on ActiveRecord::SerializationFailure
|
22
|
+
# Extend ActiveJob with `perform_transactionally` class method
|
23
|
+
include PerformTransactionallyExtension
|
24
|
+
|
25
|
+
# Ensure our `perform` method always runs first to gather parameters
|
26
|
+
prepend PerformWrapper
|
58
27
|
end
|
59
28
|
|
60
29
|
# Number of seconds passed which we consider a held idempotency key lock to be
|
@@ -63,8 +32,8 @@ module AcidicJob
|
|
63
32
|
# this might not happen 100% of the time, so this is a hedge against it.
|
64
33
|
IDEMPOTENCY_KEY_LOCK_TIMEOUT = 90
|
65
34
|
|
66
|
-
#
|
67
|
-
def idempotently(with:)
|
35
|
+
# takes a block
|
36
|
+
def idempotently(with:)
|
68
37
|
# set accessors for each argument passed in to ensure they are available
|
69
38
|
# to the step methods the job will have written
|
70
39
|
define_accessors_for_passed_arguments(with)
|
@@ -84,7 +53,7 @@ module AcidicJob
|
|
84
53
|
# close proximity, one of the two will be aborted by Postgres because we're
|
85
54
|
# using a transaction with SERIALIZABLE isolation level. It may not look
|
86
55
|
# it, but this code is safe from races.
|
87
|
-
ensure_idempotency_key_record(
|
56
|
+
ensure_idempotency_key_record(idempotency_key_value, defined_steps.first)
|
88
57
|
|
89
58
|
# if the key record is already marked as finished, immediately return its result
|
90
59
|
return @key.succeeded? if @key.finished?
|
@@ -114,10 +83,16 @@ module AcidicJob
|
|
114
83
|
@_steps
|
115
84
|
end
|
116
85
|
|
86
|
+
def safely_finish_acidic_job
|
87
|
+
# Short circuits execution by sending execution right to 'finished'.
|
88
|
+
# So, ends the job "successfully"
|
89
|
+
AcidicJob::Response.new
|
90
|
+
end
|
91
|
+
|
117
92
|
private
|
118
93
|
|
119
94
|
def atomic_phase(key, proc = nil, &block)
|
120
|
-
|
95
|
+
rescued_error = false
|
121
96
|
phase_callable = (proc || block)
|
122
97
|
|
123
98
|
begin
|
@@ -127,13 +102,15 @@ module AcidicJob
|
|
127
102
|
phase_result.call(key: key)
|
128
103
|
end
|
129
104
|
rescue StandardError => e
|
130
|
-
|
105
|
+
rescued_error = e
|
131
106
|
raise e
|
132
107
|
ensure
|
108
|
+
return unless rescued_error
|
109
|
+
|
133
110
|
# If we're leaving under an error condition, try to unlock the idempotency
|
134
|
-
# key right away so that another request can try again.
|
111
|
+
# key right away so that another request can try again.3
|
135
112
|
begin
|
136
|
-
key.update_columns(locked_at: nil, error_object:
|
113
|
+
key.update_columns(locked_at: nil, error_object: rescued_error)
|
137
114
|
rescue StandardError => e
|
138
115
|
# We're already inside an error condition, so swallow any additional
|
139
116
|
# errors from here and just send them to logs.
|
@@ -142,7 +119,7 @@ module AcidicJob
|
|
142
119
|
end
|
143
120
|
end
|
144
121
|
|
145
|
-
def ensure_idempotency_key_record(key_val,
|
122
|
+
def ensure_idempotency_key_record(key_val, first_step)
|
146
123
|
isolation_level = case ActiveRecord::Base.connection.adapter_name.downcase.to_sym
|
147
124
|
when :sqlite
|
148
125
|
:read_uncommitted
|
@@ -156,11 +133,15 @@ module AcidicJob
|
|
156
133
|
if @key
|
157
134
|
# Programs enqueuing multiple jobs with different parameters but the
|
158
135
|
# same idempotency key is a bug.
|
159
|
-
|
136
|
+
if @key.job_args != @arguments_for_perform
|
137
|
+
raise MismatchedIdempotencyKeyAndJobArguments
|
138
|
+
end
|
160
139
|
|
161
140
|
# Only acquire a lock if the key is unlocked or its lock has expired
|
162
141
|
# because the original job was long enough ago.
|
163
|
-
|
142
|
+
if @key.locked_at && @key.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
|
143
|
+
raise LockedIdempotencyKey
|
144
|
+
end
|
164
145
|
|
165
146
|
# Lock the key and update latest run unless the job is already finished.
|
166
147
|
@key.update!(last_run_at: Time.current, locked_at: Time.current) unless @key.finished?
|
@@ -171,7 +152,7 @@ module AcidicJob
|
|
171
152
|
last_run_at: Time.current,
|
172
153
|
recovery_point: first_step,
|
173
154
|
job_name: self.class.name,
|
174
|
-
job_args:
|
155
|
+
job_args: @arguments_for_perform
|
175
156
|
)
|
176
157
|
end
|
177
158
|
end
|
@@ -205,5 +186,13 @@ module AcidicJob
|
|
205
186
|
end
|
206
187
|
end
|
207
188
|
end
|
189
|
+
|
190
|
+
def idempotency_key_value
|
191
|
+
return job_id if defined?(job_id) && !job_id.nil?
|
192
|
+
return jid if defined?(jid) && !jid.nil?
|
193
|
+
|
194
|
+
require 'securerandom'
|
195
|
+
SecureRandom.hex
|
196
|
+
end
|
208
197
|
end
|
209
|
-
# rubocop:enable Metrics/ModuleLength,
|
198
|
+
# 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,17 +22,23 @@ class AcidicJobGenerator < ActiveRecord::Generators::Base
|
|
21
22
|
end
|
22
23
|
|
23
24
|
# Copies the migration template to db/migrate.
|
24
|
-
def
|
25
|
-
migration_template "
|
25
|
+
def copy_acidic_job_keys_migration_files
|
26
|
+
migration_template "create_acidic_job_keys_migration.rb.erb",
|
26
27
|
"db/migrate/create_acidic_job_keys.rb"
|
27
28
|
end
|
28
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
|
+
|
29
35
|
protected
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
+
|
37
|
+
def migration_class
|
38
|
+
if ActiveRecord::VERSION::MAJOR >= 5
|
39
|
+
ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"]
|
40
|
+
else
|
41
|
+
ActiveRecord::Migration
|
36
42
|
end
|
43
|
+
end
|
37
44
|
end
|
@@ -3,15 +3,16 @@ class CreateAcidicJobKeys < <%= migration_class %>
|
|
3
3
|
create_table :acidic_job_keys do |t|
|
4
4
|
t.string :idempotency_key, null: false
|
5
5
|
t.string :job_name, null: false
|
6
|
-
t.text :job_args, null:
|
6
|
+
t.text :job_args, null: true
|
7
7
|
t.datetime :last_run_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
8
8
|
t.datetime :locked_at, null: true
|
9
9
|
t.string :recovery_point, null: false
|
10
10
|
t.text :error_object
|
11
11
|
t.timestamps
|
12
12
|
|
13
|
-
t.index %i[idempotency_key job_name job_args],
|
14
|
-
|
13
|
+
t.index %i[idempotency_key job_name job_args],
|
14
|
+
unique: true,
|
15
|
+
name: "idx_acidic_job_keys_on_idempotency_key_n_job_name_n_job_args"
|
15
16
|
end
|
16
17
|
end
|
17
18
|
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,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.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-
|
11
|
+
date: 2021-09-28 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
|