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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34c65a77db46e2a7e38aaee61428a0739027820a7a796edf42b91e864818af22
4
- data.tar.gz: cd0cfaa3efe9244fc82ea32f197099c29dff0326534a44411e73569158396759
3
+ metadata.gz: 9878e2b82544c04809b49366f27eafcbe28683c39ca877fb99b9649c4bd4dcdd
4
+ data.tar.gz: b04206a126a00f84caeb255a6a3bb6542001a6a700345edfd45fe68d39b3fcdd
5
5
  SHA512:
6
- metadata.gz: 20158e851d244eaa05881e0ca3723397f4e48cc5b17537956f114bf5507831e9e2ddb17ffb9fa35496a50c067539dc3aa5fb4a9051dd61da71dab0e9e04c1833
7
- data.tar.gz: 52272d8d404339ecd6442118e5a5c6718febc9688905dece63bda85d9837d43edbbdfe7386284abcd773660c6b95b98b8caeb7ae6af83267ed2f6778ed952d21
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
- # Uncomment to register a new dependency of your gem
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 Error < StandardError; end
7
- # Your code goes here...
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,7 @@
1
+ # Represents an action to perform a no-op. One possible option for a return
2
+ # from an #atomic_phase block.
3
+ class NoOp
4
+ def call(_key)
5
+ # no-op
6
+ end
7
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
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.0
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: