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 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: