acidic_job 0.1.0 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/acidic_job.gemspec +1 -2
- data/lib/acidic_job.rb +215 -2
- data/lib/acidic_job/no_op.rb +7 -0
- data/lib/acidic_job/recovery_point.rb +13 -0
- data/lib/acidic_job/response.rb +11 -0
- data/lib/acidic_job/version.rb +1 -1
- metadata +19 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9878e2b82544c04809b49366f27eafcbe28683c39ca877fb99b9649c4bd4dcdd
|
4
|
+
data.tar.gz: b04206a126a00f84caeb255a6a3bb6542001a6a700345edfd45fe68d39b3fcdd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7d7928c276825f884387a6010cf57e1fc2b04f17e657c230c5d6909e8430445dfb903018643812255100ac8fdbf7dfe20fae0f5d194089aa003c7f6b4c996830
|
7
|
+
data.tar.gz: befa1e96050d612cb42fff983b751f8f04dada4f5ccd5ac81ba0190807f69e6961d1a19bdb6df06bce0e2252c60bac5dd0a37c202afa5c1f03942d46ff469828
|
data/acidic_job.gemspec
CHANGED
@@ -27,8 +27,7 @@ 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
|
-
|
31
|
-
# spec.add_dependency "example-gem", "~> 1.0"
|
30
|
+
spec.add_dependency 'activesupport'
|
32
31
|
|
33
32
|
# For more information and examples about making a new gem, checkout our
|
34
33
|
# guide at: https://bundler.io/guides/creating_gem.html
|
data/lib/acidic_job.rb
CHANGED
@@ -1,8 +1,221 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "acidic_job/version"
|
4
|
+
require_relative "acidic_job/no_op"
|
5
|
+
require_relative "acidic_job/recovery_point"
|
6
|
+
require_relative "acidic_job/response"
|
4
7
|
|
5
8
|
module AcidicJob
|
6
|
-
class
|
7
|
-
|
9
|
+
class IdempotencyKeyRequired < StandardError; end
|
10
|
+
|
11
|
+
class MissingRequiredAttribute < StandardError; end
|
12
|
+
|
13
|
+
class IdempotencyKeyTooShort < StandardError; end
|
14
|
+
|
15
|
+
class MismatchedIdempotencyKeyAndJobArguments < StandardError; end
|
16
|
+
|
17
|
+
class LockedIdempotencyKey < StandardError; end
|
18
|
+
|
19
|
+
class UnknownRecoveryPoint < StandardError; end
|
20
|
+
|
21
|
+
class UnknownAtomicPhaseType < StandardError; end
|
22
|
+
|
23
|
+
class SerializedTransactionConflict < StandardError; end
|
24
|
+
|
25
|
+
extend ActiveSupport::Concern
|
26
|
+
|
27
|
+
included do
|
28
|
+
attr_reader :key
|
29
|
+
end
|
30
|
+
|
31
|
+
class_methods do
|
32
|
+
def required(*names)
|
33
|
+
required_attributes.push(*names)
|
34
|
+
end
|
35
|
+
|
36
|
+
def required_attributes
|
37
|
+
return @required_attributes if instance_variable_defined?(:@required_attributes)
|
38
|
+
|
39
|
+
@required_attributes = []
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Number of seconds passed which we consider a held idempotency key lock to be
|
44
|
+
# defunct and eligible to be locked again by a different API call. We try to
|
45
|
+
# unlock keys on our various failure conditions, but software is buggy, and
|
46
|
+
# this might not happen 100% of the time, so this is a hedge against it.
|
47
|
+
IDEMPOTENCY_KEY_LOCK_TIMEOUT = 90
|
48
|
+
|
49
|
+
# To try and enforce some level of required randomness in an idempotency key,
|
50
|
+
# we require a minimum length. This of course is a poor approximate, and in
|
51
|
+
# real life you might want to consider trying to measure actual entropy with
|
52
|
+
# something like the Shannon entropy equation.
|
53
|
+
IDEMPOTENCY_KEY_MIN_LENGTH = 20
|
54
|
+
|
55
|
+
def idempotently(key:, with:) # &block
|
56
|
+
# set accessors for each argument passed in to ensure they are available
|
57
|
+
# to the step methods the job will have written
|
58
|
+
set_accessors_for_passed_arguments(with)
|
59
|
+
|
60
|
+
validate_passed_idempotency_key(key)
|
61
|
+
validate_passed_arguments(with)
|
62
|
+
|
63
|
+
# execute the block to gather the info on what phases are defined for this job
|
64
|
+
defined_steps = yield
|
65
|
+
|
66
|
+
# convert the array of steps into a hash of recovery_points and callable actions
|
67
|
+
phases = define_atomic_phases(defined_steps)
|
68
|
+
|
69
|
+
# find or create an AcidicJobKey record to store all information about this job
|
70
|
+
# side-effect: will set the @key instance variable
|
71
|
+
ensure_idempotency_key_record(key, params, defined_steps.first)
|
72
|
+
|
73
|
+
# if the key record is already marked as finished, immediately return its result
|
74
|
+
return @key.succeeded? if @key.finished?
|
75
|
+
|
76
|
+
# otherwise, we will enter a loop to process each required step of the job
|
77
|
+
100.times do
|
78
|
+
# our `phases` hash uses Symbols for keys
|
79
|
+
recovery_point = @key.recovery_point.to_sym
|
80
|
+
|
81
|
+
case recovery_point
|
82
|
+
when :FINISHED
|
83
|
+
break
|
84
|
+
else
|
85
|
+
raise UnknownRecoveryPoint unless phases.key? recovery_point
|
86
|
+
|
87
|
+
atomic_phase @key, phases[recovery_point]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# the loop will break once the job is finished, so simply report the status
|
92
|
+
@key.succeeded?
|
93
|
+
end
|
94
|
+
|
95
|
+
def step(method_name)
|
96
|
+
@_steps ||= []
|
97
|
+
@_steps << method_name
|
98
|
+
@_steps
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def atomic_phase(key = nil, proc = nil, &block)
|
104
|
+
error = false
|
105
|
+
phase_callable = (proc || block)
|
106
|
+
|
107
|
+
begin
|
108
|
+
# ActiveRecord::Base.transaction(isolation: :serializable) do
|
109
|
+
ActiveRecord::Base.transaction(isolation: :read_uncommitted) do
|
110
|
+
phase_result = phase_callable.call
|
111
|
+
|
112
|
+
if phase_result.is_a?(NoOp) || phase_result.is_a?(RecoveryPoint) || phase_result.is_a?(Response)
|
113
|
+
key ||= @key
|
114
|
+
phase_result.call(key: key)
|
115
|
+
else
|
116
|
+
raise UnknownAtomicPhaseType
|
117
|
+
end
|
118
|
+
end
|
119
|
+
rescue MismatchedIdempotencyKeyAndJobArguments, LockedIdempotencyKey, UnknownRecoveryPoint, UnknownAtomicPhaseType, MissingRequiredAttribute, ActiveRecord::SerializationFailure => e
|
120
|
+
error = e
|
121
|
+
raise e
|
122
|
+
rescue => e
|
123
|
+
error = e
|
124
|
+
raise e
|
125
|
+
ensure
|
126
|
+
# If we're leaving under an error condition, try to unlock the idempotency
|
127
|
+
# key right away so that another request can try again.
|
128
|
+
if error && !key.nil?
|
129
|
+
begin
|
130
|
+
key.update(locked_at: nil, error_object: error)
|
131
|
+
rescue => e
|
132
|
+
# We're already inside an error condition, so swallow any additional
|
133
|
+
# errors from here and just send them to logs.
|
134
|
+
puts "Failed to unlock key #{key.id} because of #{e}."
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def ensure_idempotency_key_record(key_val, params, first_step)
|
141
|
+
atomic_phase do
|
142
|
+
@key = AcidicJobKey.find_by(idempotency_key: key_val)
|
143
|
+
|
144
|
+
if @key
|
145
|
+
# Programs enqueuing multiple jobs with different parameters but the
|
146
|
+
# same idempotency key is a bug.
|
147
|
+
if @key.job_args != params.as_json
|
148
|
+
raise MismatchedIdempotencyKeyAndJobArguments
|
149
|
+
end
|
150
|
+
|
151
|
+
# Only acquire a lock if the key is unlocked or its lock has expired
|
152
|
+
# because the original job was long enough ago.
|
153
|
+
if @key.locked_at && @key.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
|
154
|
+
raise LockedIdempotencyKey
|
155
|
+
end
|
156
|
+
|
157
|
+
# Lock the key and update latest run unless the job is already
|
158
|
+
# finished.
|
159
|
+
if !@key.finished?
|
160
|
+
@key.update!(last_run_at: Time.current, locked_at: Time.current)
|
161
|
+
end
|
162
|
+
else
|
163
|
+
@key = AcidicJobKey.create!(
|
164
|
+
idempotency_key: key_val,
|
165
|
+
locked_at: Time.current,
|
166
|
+
last_run_at: Time.current,
|
167
|
+
recovery_point: first_step,
|
168
|
+
job_name: self.class.name,
|
169
|
+
job_args: params.as_json
|
170
|
+
)
|
171
|
+
end
|
172
|
+
|
173
|
+
# no response and no need to set a recovery point
|
174
|
+
NoOp.new
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def set_accessors_for_passed_arguments(passed_arguments)
|
179
|
+
passed_arguments.each do |accessor, value|
|
180
|
+
self.class.attr_accessor accessor
|
181
|
+
instance_variable_set("@#{accessor}", value)
|
182
|
+
end
|
183
|
+
|
184
|
+
true
|
185
|
+
end
|
186
|
+
|
187
|
+
def validate_passed_idempotency_key(key)
|
188
|
+
raise IdempotencyKeyRequired if key.nil?
|
189
|
+
raise IdempotencyKeyTooShort if key.length < IDEMPOTENCY_KEY_MIN_LENGTH
|
190
|
+
|
191
|
+
true
|
192
|
+
end
|
193
|
+
|
194
|
+
def validate_passed_arguments(attributes)
|
195
|
+
missing_attributes = self.class.required_attributes.select do |required_attribute|
|
196
|
+
attributes[required_attribute].nil?
|
197
|
+
end
|
198
|
+
|
199
|
+
return if missing_attributes.empty?
|
200
|
+
|
201
|
+
raise MissingRequiredAttribute, "The following required job parameters are missing: #{missing_attributes.to_sentence}"
|
202
|
+
end
|
203
|
+
|
204
|
+
def define_atomic_phases(defined_steps)
|
205
|
+
defined_steps << :FINISHED
|
206
|
+
|
207
|
+
{}.tap do |phases|
|
208
|
+
defined_steps.each_cons(2).map do |enter_method, exit_method|
|
209
|
+
phases[enter_method] = lambda do
|
210
|
+
method(enter_method).call
|
211
|
+
|
212
|
+
if exit_method == :FINISHED
|
213
|
+
Response.new
|
214
|
+
else
|
215
|
+
RecoveryPoint.new(exit_method)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
8
221
|
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# Represents an action to set a new recovery point. One possible option for a
|
2
|
+
# return from an #atomic_phase block.
|
3
|
+
class RecoveryPoint
|
4
|
+
attr_accessor :name
|
5
|
+
|
6
|
+
def initialize(name)
|
7
|
+
self.name = name
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(key:)
|
11
|
+
key.update(recovery_point: name)
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# Represents an action to set a new API response (which will be stored onto an
|
2
|
+
# idempotency key). One possible option for a return from an #atomic_phase
|
3
|
+
# block.
|
4
|
+
class Response
|
5
|
+
def call(key:)
|
6
|
+
key.update!(
|
7
|
+
locked_at: nil,
|
8
|
+
recovery_point: :FINISHED
|
9
|
+
)
|
10
|
+
end
|
11
|
+
end
|
data/lib/acidic_job/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: acidic_job
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- fractaledmind
|
@@ -9,7 +9,21 @@ autorequire:
|
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
11
|
date: 2021-06-20 00:00:00.000000000 Z
|
12
|
-
dependencies:
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
13
27
|
description: Idempotent operations for Rails apps, built on top of ActiveJob.
|
14
28
|
email:
|
15
29
|
- stephen.margheim@gmail.com
|
@@ -29,6 +43,9 @@ files:
|
|
29
43
|
- bin/console
|
30
44
|
- bin/setup
|
31
45
|
- lib/acidic_job.rb
|
46
|
+
- lib/acidic_job/no_op.rb
|
47
|
+
- lib/acidic_job/recovery_point.rb
|
48
|
+
- lib/acidic_job/response.rb
|
32
49
|
- lib/acidic_job/version.rb
|
33
50
|
homepage: https://github.com/fractaledmind/acidic_job
|
34
51
|
licenses:
|