acidic_job 0.7.5 → 1.0.0.beta.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +35 -12
  3. data/.gitignore +6 -0
  4. data/.rubocop.yml +21 -0
  5. data/.tool-versions +1 -0
  6. data/Gemfile +0 -25
  7. data/Gemfile.lock +181 -73
  8. data/README.md +274 -20
  9. data/UPGRADE_GUIDE.md +81 -0
  10. data/acidic_job.gemspec +17 -2
  11. data/bin/console +5 -2
  12. data/bin/sandbox +1958 -0
  13. data/combustion/log/test.log +0 -0
  14. data/gemfiles/rails_6.1.gemfile +8 -0
  15. data/gemfiles/rails_7.0.gemfile +8 -0
  16. data/lib/acidic_job/active_kiq.rb +114 -0
  17. data/lib/acidic_job/arguments.rb +22 -0
  18. data/lib/acidic_job/base.rb +11 -0
  19. data/lib/acidic_job/errors.rb +10 -17
  20. data/lib/acidic_job/{response.rb → finished_point.rb} +5 -7
  21. data/lib/acidic_job/idempotency_key.rb +27 -0
  22. data/lib/acidic_job/logger.rb +31 -0
  23. data/lib/acidic_job/mixin.rb +253 -0
  24. data/lib/acidic_job/perform_wrapper.rb +13 -26
  25. data/lib/acidic_job/processor.rb +96 -0
  26. data/lib/acidic_job/recovery_point.rb +4 -5
  27. data/lib/acidic_job/run.rb +299 -0
  28. data/lib/acidic_job/serializer.rb +24 -0
  29. data/lib/acidic_job/serializers/exception_serializer.rb +41 -0
  30. data/lib/acidic_job/serializers/finished_point_serializer.rb +24 -0
  31. data/lib/acidic_job/serializers/job_serializer.rb +35 -0
  32. data/lib/acidic_job/serializers/range_serializer.rb +28 -0
  33. data/lib/acidic_job/serializers/recovery_point_serializer.rb +25 -0
  34. data/lib/acidic_job/serializers/worker_serializer.rb +27 -0
  35. data/lib/acidic_job/version.rb +1 -1
  36. data/lib/acidic_job/workflow.rb +78 -0
  37. data/lib/acidic_job/workflow_builder.rb +35 -0
  38. data/lib/acidic_job/workflow_step.rb +103 -0
  39. data/lib/acidic_job.rb +29 -330
  40. data/lib/generators/acidic_job/drop_tables_generator.rb +26 -0
  41. data/lib/generators/acidic_job/install_generator.rb +27 -0
  42. data/lib/generators/acidic_job/templates/create_acidic_job_runs_migration.rb.erb +19 -0
  43. data/lib/generators/{templates/create_acidic_job_keys_migration.rb.erb → acidic_job/templates/drop_acidic_job_keys_migration.rb.erb} +10 -3
  44. metadata +235 -20
  45. data/.ruby_version +0 -1
  46. data/lib/acidic_job/deliver_transactionally_extension.rb +0 -26
  47. data/lib/acidic_job/key.rb +0 -33
  48. data/lib/acidic_job/no_op.rb +0 -11
  49. data/lib/acidic_job/perform_transactionally_extension.rb +0 -33
  50. data/lib/acidic_job/sidekiq_callbacks.rb +0 -45
  51. data/lib/acidic_job/staged.rb +0 -50
  52. data/lib/generators/acidic_job_generator.rb +0 -44
  53. data/lib/generators/templates/create_staged_acidic_jobs_migration.rb.erb +0 -10
@@ -4,15 +4,14 @@
4
4
  # return from an #atomic_phase block.
5
5
  module AcidicJob
6
6
  class RecoveryPoint
7
- attr_accessor :name
7
+ attr_reader :name
8
8
 
9
9
  def initialize(name)
10
- self.name = name
10
+ @name = name
11
11
  end
12
12
 
13
- def call(key:)
14
- # Skip AR callbacks as there are none on the model
15
- key.update_column(:recovery_point, name)
13
+ def call(run:)
14
+ run.recover_to!(@name)
16
15
  end
17
16
  end
18
17
  end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "global_id"
5
+ require "base64"
6
+ require "active_support/core_ext/object/with_options"
7
+ require "active_support/core_ext/module/concerning"
8
+ require "active_support/concern"
9
+
10
+ module AcidicJob
11
+ class Run < ActiveRecord::Base
12
+ include GlobalID::Identification
13
+
14
+ FINISHED_RECOVERY_POINT = "FINISHED"
15
+ STAGED_JOB_ID_PREFIX = "STG"
16
+ STAGED_JOB_ID_DELIMITER = "__"
17
+ IDEMPOTENCY_KEY_LOCK_TIMEOUT_SECONDS = 2
18
+
19
+ self.table_name = "acidic_job_runs"
20
+
21
+ validates :idempotency_key, presence: true
22
+ validate :not_awaited_but_unstaged
23
+
24
+ def self.clear_finished
25
+ # over-write any pre-existing relation queries on `recovery_point` and/or `error_object`
26
+ to_purge = finished
27
+
28
+ count = to_purge.count
29
+
30
+ return 0 if count.zero?
31
+
32
+ AcidicJob.logger.info("Deleting #{count} finished AcidicJob runs")
33
+ to_purge.delete_all
34
+ end
35
+
36
+ def succeeded?
37
+ finished? && !errored?
38
+ end
39
+
40
+ concerning :Awaitable do
41
+ included do
42
+ belongs_to :awaited_by, class_name: "AcidicJob::Run", optional: true
43
+ has_many :batched_runs, class_name: "AcidicJob::Run", foreign_key: "awaited_by_id"
44
+
45
+ scope :awaited, -> { where.not(awaited_by: nil) }
46
+ scope :unawaited, -> { where(awaited_by: nil) }
47
+
48
+ after_update_commit :proceed_with_parent, if: :finished?
49
+
50
+ serialize :returning_to, AcidicJob::Serializer
51
+ end
52
+
53
+ class_methods do
54
+ def await!(job, by:, return_to:)
55
+ create!(
56
+ staged: true,
57
+ awaited_by: by,
58
+ job_class: job.class.name,
59
+ serialized_job: job.serialize,
60
+ idempotency_key: job.idempotency_key
61
+ )
62
+ by.update(returning_to: return_to)
63
+ end
64
+ end
65
+
66
+ def awaited?
67
+ awaited_by.present?
68
+ end
69
+
70
+ private
71
+
72
+ def proceed_with_parent
73
+ return unless finished?
74
+ return unless awaited_by.present?
75
+ return if awaited_by.batched_runs.outstanding.any?
76
+
77
+ AcidicJob.logger.log_run_event("Proceeding with parent job...", job, self)
78
+ awaited_by.unlock!
79
+ awaited_by.proceed
80
+ AcidicJob.logger.log_run_event("Proceeded with parent job.", job, self)
81
+ end
82
+
83
+ protected
84
+
85
+ def proceed
86
+ # this needs to be explicitly set so that `was_workflow_job?` appropriately returns `true`
87
+ # TODO: replace this with some way to check the type of the job directly
88
+ # either via class method or explicit module inclusion
89
+ job.instance_variable_set(:@acidic_job_run, self)
90
+
91
+ workflow = Workflow.new(self, job, returning_to)
92
+ # TODO: WRITE REGRESSION TESTS FOR PARALLEL JOB FAILING AND RETRYING THE ORIGINAL STEP
93
+ workflow.progress_to_next_step
94
+
95
+ # when a batch of jobs for a step succeeds, we begin processing the `AcidicJob::Run` record again
96
+ return if finished?
97
+
98
+ AcidicJob.logger.log_run_event("Re-enqueuing parent job...", job, self)
99
+ enqueue_job
100
+ AcidicJob.logger.log_run_event("Re-enqueued parent job.", job, self)
101
+ end
102
+ end
103
+
104
+ concerning :Stageable do
105
+ included do
106
+ after_create_commit :enqueue_job, if: :staged?
107
+
108
+ validates :staged, inclusion: { in: [true, false] } # uses database default
109
+
110
+ scope :staged, -> { where(staged: true) }
111
+ scope :unstaged, -> { where(staged: false) }
112
+ end
113
+
114
+ class_methods do
115
+ def stage!(job)
116
+ create!(
117
+ staged: true,
118
+ job_class: job.class.name,
119
+ serialized_job: job.serialize,
120
+ idempotency_key: job.idempotency_key
121
+ )
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def job_id
128
+ return idempotency_key unless staged?
129
+
130
+ # encode the identifier for this record in the job ID
131
+ global_id = to_global_id.to_s.remove("gid://")
132
+ # base64 encoding for minimal security
133
+ encoded_global_id = Base64.encode64(global_id).strip
134
+
135
+ [
136
+ STAGED_JOB_ID_PREFIX,
137
+ idempotency_key,
138
+ encoded_global_id
139
+ ].join(STAGED_JOB_ID_DELIMITER)
140
+ end
141
+ end
142
+
143
+ concerning :Workflowable do
144
+ included do
145
+ serialize :workflow, AcidicJob::Serializer
146
+ serialize :error_object, AcidicJob::Serializer
147
+ store :attr_accessors, coder: AcidicJob::Serializer
148
+
149
+ with_options unless: :staged? do
150
+ validates :last_run_at, presence: true
151
+ validates :recovery_point, presence: true
152
+ validates :workflow, presence: true
153
+ end
154
+ end
155
+
156
+ def workflow?
157
+ self[:workflow].present?
158
+ end
159
+
160
+ def attr_accessors
161
+ self[:attr_accessors] || {}
162
+ end
163
+
164
+ def current_step_name
165
+ recovery_point
166
+ end
167
+
168
+ def current_step_hash
169
+ workflow[current_step_name]
170
+ end
171
+
172
+ def next_step_name
173
+ current_step_hash.fetch("then")
174
+ end
175
+
176
+ def next_step_finishes?
177
+ next_step_name.to_s == FINISHED_RECOVERY_POINT
178
+ end
179
+
180
+ def current_step_finished?
181
+ current_step_name.to_s == FINISHED_RECOVERY_POINT
182
+ end
183
+ end
184
+
185
+ concerning :Jobbable do
186
+ included do
187
+ serialize :serialized_job, JSON
188
+
189
+ validates :serialized_job, presence: true
190
+ validates :job_class, presence: true
191
+ end
192
+
193
+ def job
194
+ return @job if defined? @job
195
+
196
+ serialized_job_for_run = serialized_job.merge("job_id" => job_id)
197
+ job_class_for_run = job_class.constantize
198
+
199
+ @job = job_class_for_run.deserialize(serialized_job_for_run)
200
+ end
201
+
202
+ def enqueue_job
203
+ job.enqueue
204
+
205
+ # NOTE: record will be deleted after the job has successfully been performed
206
+ true
207
+ end
208
+ end
209
+
210
+ concerning :Finishable do
211
+ included do
212
+ scope :finished, -> { where(recovery_point: FINISHED_RECOVERY_POINT) }
213
+ scope :outstanding, lambda {
214
+ where.not(recovery_point: FINISHED_RECOVERY_POINT).or(where(recovery_point: [nil, ""]))
215
+ }
216
+ end
217
+
218
+ def finish!
219
+ finish and unlock and save!
220
+ end
221
+
222
+ def finish
223
+ self.recovery_point = FINISHED_RECOVERY_POINT
224
+ self
225
+ end
226
+
227
+ def finished?
228
+ recovery_point.to_s == FINISHED_RECOVERY_POINT
229
+ end
230
+ end
231
+
232
+ concerning :Unlockable do
233
+ included do
234
+ scope :unlocked, -> { where(locked_at: nil) }
235
+ scope :locked, -> { where.not(locked_at: nil) }
236
+ end
237
+
238
+ def unlock!
239
+ unlock and save!
240
+ end
241
+
242
+ def unlock
243
+ self.locked_at = nil
244
+ self
245
+ end
246
+
247
+ def locked?
248
+ locked_at.present?
249
+ end
250
+
251
+ def lock_active?
252
+ return false if locked_at.nil?
253
+
254
+ locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT_SECONDS
255
+ end
256
+ end
257
+
258
+ concerning :ErrorStoreable do
259
+ included do
260
+ scope :unerrored, -> { where(error_object: nil) }
261
+ scope :errored, -> { where.not(error_object: nil) }
262
+ end
263
+
264
+ def store_error!(error)
265
+ reload and unlock and store_error(error) and save!
266
+ end
267
+
268
+ def store_error(error)
269
+ self.error_object = error
270
+ self
271
+ end
272
+
273
+ def errored?
274
+ error_object.present?
275
+ end
276
+ end
277
+
278
+ concerning :Recoverable do
279
+ def recover_to!(point)
280
+ recover_to(point) and save!
281
+ end
282
+
283
+ def recover_to(point)
284
+ self.recovery_point = point
285
+ self
286
+ end
287
+
288
+ def known_recovery_point?
289
+ workflow.key?(recovery_point)
290
+ end
291
+ end
292
+
293
+ def not_awaited_but_unstaged
294
+ return true unless awaited? && !staged?
295
+
296
+ errors.add(:base, "cannot be awaited by another job but not staged")
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AcidicJob
6
+ class Serializer
7
+ # Used for `serialize` method in ActiveRecord
8
+ class << self
9
+ def load(json)
10
+ return if json.nil? || json.empty?
11
+
12
+ data = JSON.parse(json)
13
+ Arguments.deserialize(data).first
14
+ end
15
+
16
+ def dump(obj)
17
+ data = Arguments.serialize [obj]
18
+ data.to_json
19
+ rescue ActiveJob::SerializationError
20
+ raise UnserializableValue
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/serializers/object_serializer"
4
+
5
+ module AcidicJob
6
+ module Serializers
7
+ class ExceptionSerializer < ::ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(exception)
9
+ hash = {
10
+ "class" => exception.class.name,
11
+ "message" => exception.message,
12
+ "cause" => exception.cause,
13
+ "backtrace" => {}
14
+ }
15
+
16
+ exception.backtrace.map do |trace|
17
+ path, _, location = trace.rpartition("/")
18
+
19
+ next if hash["backtrace"].key?(path)
20
+
21
+ hash["backtrace"][path] = location
22
+ end
23
+
24
+ super(hash)
25
+ end
26
+
27
+ def deserialize(hash)
28
+ exception_class = hash["class"].constantize
29
+ exception = exception_class.new(hash["message"])
30
+ exception.set_backtrace(hash["backtrace"].map do |path, location|
31
+ [path, location].join("/")
32
+ end)
33
+ exception
34
+ end
35
+
36
+ def serialize?(argument)
37
+ defined?(Exception) && argument.is_a?(Exception)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/serializers/object_serializer"
4
+
5
+ module AcidicJob
6
+ module Serializers
7
+ class FinishedPointSerializer < ::ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(finished_point)
9
+ super(
10
+ "class" => finished_point.class.name
11
+ )
12
+ end
13
+
14
+ def deserialize(hash)
15
+ finished_point_class = hash["class"].constantize
16
+ finished_point_class.new
17
+ end
18
+
19
+ def serialize?(argument)
20
+ defined?(::AcidicJob::FinishedPoint) && argument.is_a?(::AcidicJob::FinishedPoint)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/serializers/object_serializer"
4
+
5
+ module AcidicJob
6
+ module Serializers
7
+ class JobSerializer < ::ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(job)
9
+ super(job.serialize)
10
+ end
11
+
12
+ def deserialize(hash)
13
+ job = ActiveJob::Base.deserialize(hash)
14
+ job.send(:deserialize_arguments_if_needed)
15
+ # this is a shim to ensure we can work with Ruby 2.7 as well as 3.0+
16
+ # :nocov:
17
+ if job.arguments.last.is_a?(Hash)
18
+ *args, kwargs = job.arguments
19
+ else
20
+ args = job.arguments
21
+ kwargs = {}
22
+ end
23
+ # :nocov:
24
+ job.instance_variable_set(:@__acidic_job_args, args)
25
+ job.instance_variable_set(:@__acidic_job_kwargs, kwargs)
26
+
27
+ job
28
+ end
29
+
30
+ def serialize?(argument)
31
+ defined?(::ActiveJob::Base) && argument.class < ::ActiveJob::Base
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/serializers/object_serializer"
4
+
5
+ # :nocov:
6
+ module AcidicJob
7
+ module Serializers
8
+ class RangeSerializer < ::ActiveJob::Serializers::ObjectSerializer
9
+ KEYS = %w[begin end exclude_end].freeze
10
+
11
+ def serialize(range)
12
+ args = Arguments.serialize([range.begin, range.end, range.exclude_end?])
13
+ super(KEYS.zip(args).to_h)
14
+ end
15
+
16
+ def deserialize(hash)
17
+ klass.new(*Arguments.deserialize(hash.values_at(*KEYS)))
18
+ end
19
+
20
+ private
21
+
22
+ def klass
23
+ ::Range
24
+ end
25
+ end
26
+ end
27
+ end
28
+ # :nocov:
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/serializers/object_serializer"
4
+
5
+ module AcidicJob
6
+ module Serializers
7
+ class RecoveryPointSerializer < ::ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(recovery_point)
9
+ super(
10
+ "class" => recovery_point.class.name,
11
+ "name" => recovery_point.name
12
+ )
13
+ end
14
+
15
+ def deserialize(hash)
16
+ recovery_point_class = hash["class"].constantize
17
+ recovery_point_class.new(hash["name"])
18
+ end
19
+
20
+ def serialize?(argument)
21
+ defined?(::AcidicJob::RecoveryPoint) && argument.is_a?(::AcidicJob::RecoveryPoint)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/serializers/object_serializer"
4
+
5
+ # :nocov:
6
+ module AcidicJob
7
+ module Serializers
8
+ class WorkerSerializer < ::ActiveJob::Serializers::ObjectSerializer
9
+ def serialize(worker)
10
+ super(
11
+ "job_class" => worker.class.name,
12
+ "arguments" => worker.arguments,
13
+ )
14
+ end
15
+
16
+ def deserialize(hash)
17
+ worker_class = hash["job_class"].constantize
18
+ worker_class.new(*hash["arguments"])
19
+ end
20
+
21
+ def serialize?(argument)
22
+ defined?(::Sidekiq) && argument.class.include?(::Sidekiq::Worker)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ # :nocov:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
- VERSION = "0.7.5"
4
+ VERSION = "1.0.0.beta.1"
5
5
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class Workflow
5
+ # { "step 1": { does: "step 1", awaits: [], then: "step 2" }, ... }
6
+ def initialize(run, job, step_result = nil)
7
+ @run = run
8
+ @job = job
9
+ @step_result = step_result
10
+ end
11
+
12
+ def execute_current_step
13
+ rescued_error = false
14
+
15
+ begin
16
+ run_current_step
17
+ rescue StandardError => e
18
+ rescued_error = e
19
+ raise e
20
+ ensure
21
+ if rescued_error
22
+ begin
23
+ @run.store_error!(rescued_error)
24
+ rescue StandardError => e
25
+ # We're already inside an error condition, so swallow any additional
26
+ # errors from here and just send them to logs.
27
+ AcidicJob.logger.error("Failed to unlock AcidicJob::Run #{@run.id} because of #{e}.")
28
+ end
29
+ end
30
+ end
31
+
32
+ # be sure to return the `step_result`
33
+ # which is set by `run_current_step`
34
+ # which runs the (wrapped) current step method
35
+ @step_result
36
+ end
37
+
38
+ def progress_to_next_step
39
+ return if @run.current_step_finished?
40
+ return run_step_result unless @run.next_step_finishes?
41
+
42
+ @job.run_callbacks :finish do
43
+ run_step_result
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def run_current_step
50
+ wrapped_method = WorkflowStep.new(run: @run, job: @job).wrapped
51
+ current_step = @run.current_step_name
52
+
53
+ # can't reproduce yet, but saw a bug in production where
54
+ # nested awaits workflows had an unsaved `workflow` attribute
55
+ @run.save! if @run.has_changes_to_save?
56
+
57
+ AcidicJob.logger.log_run_event("Executing #{current_step}...", @job, @run)
58
+ @run.with_lock do
59
+ @step_result = wrapped_method.call(@run)
60
+ end
61
+ AcidicJob.logger.log_run_event("Executed #{current_step}.", @job, @run)
62
+ end
63
+
64
+ def run_step_result
65
+ next_step = @run.next_step_name
66
+
67
+ # can't reproduce yet, but saw a bug in production where
68
+ # nested awaits workflows had an unsaved `workflow` attribute
69
+ @run.save! if @run.has_changes_to_save?
70
+
71
+ AcidicJob.logger.log_run_event("Progressing to #{next_step}...", @job, @run)
72
+ @run.with_lock do
73
+ @step_result.call(run: @run)
74
+ end
75
+ AcidicJob.logger.log_run_event("Progressed to #{next_step}.", @job, @run)
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class WorkflowBuilder
5
+ attr_reader :steps
6
+
7
+ def initialize
8
+ @steps = []
9
+ end
10
+
11
+ def step(method_name, awaits: [], for_each: nil)
12
+ @steps << {
13
+ "does" => method_name.to_s,
14
+ "awaits" => awaits,
15
+ "for_each" => for_each
16
+ }
17
+
18
+ @steps
19
+ end
20
+ alias_method "✅", :step
21
+
22
+ def define_workflow
23
+ # [ { does: "step 1", awaits: [] }, { does: "step 2", awaits: [] }, ... ]
24
+ @steps << { "does" => Run::FINISHED_RECOVERY_POINT.to_s }
25
+
26
+ {}.tap do |workflow|
27
+ @steps.each_cons(2).map do |enter_step, exit_step|
28
+ enter_name = enter_step["does"]
29
+ workflow[enter_name] = enter_step.merge("then" => exit_step["does"])
30
+ end
31
+ end
32
+ # { "step 1": { does: "step 1", awaits: [], then: "step 2" }, ... }
33
+ end
34
+ end
35
+ end