acidic_job 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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:
|