acidic_job 0.7.7 → 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 -334
  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
File without changes
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activemodel", "~> 6.1.0"
6
+ gem "railties", "~> 6.1.0"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activemodel", "~> 7.0.0"
6
+ gem "railties", "~> 7.0.0"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+ require "active_support/callbacks"
5
+ require "active_support/core_ext/module/concerning"
6
+ require_relative "mixin"
7
+
8
+ module AcidicJob
9
+ class ActiveKiq
10
+ include ::Sidekiq::Worker
11
+ include ::Sidekiq::JobUtil
12
+ include ::ActiveSupport::Callbacks
13
+ define_callbacks :perform
14
+ include Mixin
15
+
16
+ concerning :Initializing do
17
+ included do
18
+ attr_accessor :arguments
19
+ attr_accessor :job_id
20
+ attr_accessor :queue_name
21
+ attr_accessor :sidekiq_options
22
+ end
23
+ ##
24
+ # Creates a new job instance.
25
+ # +args+ are the arguments, if any, that will be passed to the perform method
26
+ # +opts+ are any options to configure the job
27
+ def initialize(*arguments)
28
+ @arguments = arguments
29
+ @job_id = SecureRandom.uuid
30
+ @sidekiq_options = sidekiq_options_hash || Sidekiq.default_job_options
31
+ @queue_name = @sidekiq_options["queue"]
32
+ end
33
+
34
+ # Sidekiq sets the `jid` when it is processing jobs off of the Redis queue.
35
+ # We override the job identifier when staging jobs to encode the `Run` record global id.
36
+ # We also override how "ActiveKiq" instance's expose the job identifier to match ActiveJob.
37
+ # So, we need to ensure that when `jid=` is called, we set the `job_id` instead.
38
+ def jid=(value)
39
+ super
40
+ @job_id = value
41
+ end
42
+ end
43
+
44
+ concerning :Performing do
45
+ class_methods do
46
+ def perform_now(*args, **kwargs)
47
+ new.perform(*args, **kwargs)
48
+ end
49
+ end
50
+
51
+ def perform_now(*args, **kwargs)
52
+ perform(*args, **kwargs)
53
+ end
54
+
55
+ def enqueue
56
+ ::Sidekiq::Client.push(
57
+ "class" => self.class,
58
+ "args" => @arguments,
59
+ "jid" => @job_id,
60
+ "queue" => @queue_name
61
+ )
62
+ end
63
+ end
64
+
65
+ concerning :Serializing do
66
+ class_methods do
67
+ def deserialize(job_data)
68
+ job = job_data["job_class"].constantize.new
69
+ job.deserialize(job_data)
70
+ job
71
+ end
72
+ end
73
+
74
+ def serialize
75
+ return @serialize if defined? @serialize
76
+
77
+ item = @sidekiq_options.merge("class" => self.class.name, "args" => @arguments || [])
78
+ worker_hash = normalize_item(item)
79
+
80
+ @serialize = {
81
+ "job_class" => worker_hash["class"],
82
+ "job_id" => @job_id,
83
+ "queue_name" => worker_hash["queue"],
84
+ "arguments" => worker_hash["args"]
85
+ }.merge(worker_hash.except("class", "jid", "queue", "args"))
86
+ end
87
+
88
+ def deserialize(job_data)
89
+ self.job_id = job_data["job_id"]
90
+ self.queue_name = job_data["queue_name"]
91
+ self.arguments = job_data["arguments"]
92
+ self
93
+ end
94
+ end
95
+
96
+ # Following approach used by ActiveJob
97
+ # https://github.com/rails/rails/blob/93c9534c9871d4adad4bc33b5edc355672b59c61/activejob/lib/active_job/callbacks.rb
98
+ concerning :Callbacks do
99
+ class_methods do
100
+ def around_perform(*filters, &blk)
101
+ set_callback(:perform, :around, *filters, &blk)
102
+ end
103
+
104
+ def before_perform(*filters, &blk)
105
+ set_callback(:perform, :before, *filters, &blk)
106
+ end
107
+
108
+ def after_perform(*filters, &blk)
109
+ set_callback(:perform, :after, *filters, &blk)
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/arguments"
4
+
5
+ module AcidicJob
6
+ module Arguments
7
+ include ActiveJob::Arguments
8
+ extend self # rubocop:disable Style/ModuleFunction
9
+
10
+ # `ActiveJob` will throw an error if it tries to deserialize a GlobalID record.
11
+ # However, this isn't the behavior that we want for our custom `ActiveRecord` serializer.
12
+ # Since `ActiveRecord` does _not_ reset instance record state to its pre-transactional state
13
+ # on a transaction ROLLBACK, we can have GlobalID entries in a serialized column that point to
14
+ # non-persisted records. This is ok. We should simply return `nil` for that portion of the
15
+ # serialized field.
16
+ def deserialize_global_id(hash)
17
+ GlobalID::Locator.locate hash[GLOBALID_KEY]
18
+ rescue ActiveRecord::RecordNotFound
19
+ nil
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/queue_adapters"
4
+ require "active_job/base"
5
+ require_relative "mixin"
6
+
7
+ module AcidicJob
8
+ class Base < ActiveJob::Base
9
+ include Mixin
10
+ end
11
+ end
@@ -2,24 +2,17 @@
2
2
 
3
3
  module AcidicJob
4
4
  class Error < StandardError; end
5
-
6
- class MismatchedIdempotencyKeyAndJobArguments < Error; end
7
-
8
- class LockedIdempotencyKey < Error; end
9
-
5
+ class MissingWorkflowBlock < Error; end
10
6
  class UnknownRecoveryPoint < Error; end
11
-
12
- class UnknownAtomicPhaseType < Error; end
13
-
14
- class SerializedTransactionConflict < Error; end
15
-
16
- class UnknownJobAdapter < Error; end
17
-
18
7
  class NoDefinedSteps < Error; end
19
-
20
- class SidekiqBatchRequired < Error; end
21
-
8
+ class RedefiningWorkflow < Error; end
9
+ class UndefinedStepMethod < Error; end
10
+ class UnknownForEachCollection < Error; end
11
+ class UniterableForEachCollection < Error; end
12
+ class UnknownJobAdapter < Error; end
13
+ class UnknownAwaitedJob < Error; end
22
14
  class TooManyParametersForStepMethod < Error; end
23
-
24
- class TooManyParametersForParallelJob < Error; end
15
+ class UnserializableValue < Error; end
16
+ class LockedIdempotencyKey < Error; end
17
+ class MismatchedIdempotencyKeyAndJobArguments < Error; end
25
18
  end
@@ -1,16 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "run"
4
+
3
5
  # Represents an action to set a new API response (which will be stored onto an
4
6
  # idempotency key). One possible option for a return from an #atomic_phase
5
7
  # block.
6
8
  module AcidicJob
7
- class Response
8
- def call(key:)
9
- # Skip AR callbacks as there are none on the model
10
- key.update_columns(
11
- locked_at: nil,
12
- recovery_point: Key::RECOVERY_POINT_FINISHED
13
- )
9
+ class FinishedPoint
10
+ def call(run:)
11
+ run.finish!
14
12
  end
15
13
  end
16
14
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class IdempotencyKey
5
+ def initialize(job)
6
+ @job = job
7
+ end
8
+
9
+ def value(acidic_by: :job_id)
10
+ case acidic_by
11
+ when Proc
12
+ proc_result = @job.instance_exec(&acidic_by)
13
+ Digest::SHA1.hexdigest [@job.class.name, proc_result].flatten.join
14
+ when :job_arguments
15
+ Digest::SHA1.hexdigest [@job.class.name, @job.arguments].flatten.join
16
+ else
17
+ if @job.job_id.start_with? Run::STAGED_JOB_ID_PREFIX
18
+ # "STG__#{idempotency_key}__#{encoded_global_id}"
19
+ _prefix, idempotency_key, _encoded_global_id = @job.job_id.split("__")
20
+ idempotency_key
21
+ else
22
+ @job.job_id
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "active_support/tagged_logging"
5
+
6
+ module AcidicJob
7
+ class Logger < ::Logger
8
+ def log_run_event(msg, job = nil, run = nil)
9
+ tags = [
10
+ run&.idempotency_key,
11
+ inspect_name(job)
12
+ ].compact
13
+
14
+ tagged(*tags) { debug(msg) }
15
+ end
16
+
17
+ def inspect_name(obj)
18
+ return if obj.nil?
19
+
20
+ obj.inspect.split.first.remove("#<")
21
+ end
22
+ end
23
+
24
+ def self.logger
25
+ @logger ||= ActiveSupport::TaggedLogging.new(AcidicJob::Logger.new($stdout, level: :debug))
26
+ end
27
+
28
+ def self.silence_logger!
29
+ @logger = ActiveSupport::TaggedLogging.new(AcidicJob::Logger.new(IO::NULL, level: :debug))
30
+ end
31
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module AcidicJob
6
+ module Mixin
7
+ extend ActiveSupport::Concern
8
+
9
+ def self.wire_up(other)
10
+ raise UnknownJobAdapter unless (defined?(::ActiveJob::Base) && other < ::ActiveJob::Base) ||
11
+ (defined?(::Sidekiq::Worker) && other.include?(::Sidekiq::Worker))
12
+
13
+ # Ensure our `perform` method always runs first to gather parameters
14
+ # and run perform callbacks for Sidekiq workers
15
+ other.prepend PerformWrapper
16
+
17
+ # By default, we unique job runs by the `job_id`
18
+ other.instance_variable_set(:@acidic_identifier, :job_id)
19
+ # However, you can customize this behavior on a per job class level
20
+ other.define_singleton_method(:acidic_by_job_identifier) { @acidic_identifier = :job_id }
21
+ # You could unique job runs by the arguments passed to the job (e.g. memoization)
22
+ other.define_singleton_method(:acidic_by_job_arguments) { @acidic_identifier = :job_arguments }
23
+ # Or, you could unique jobs run by any logic you'd like using a block
24
+ other.define_singleton_method(:acidic_by) { |&block| @acidic_identifier = block }
25
+
26
+ # We add a callback to ensure that staged, non-workflow jobs are "finished" after they are "performed".
27
+ # This allows us to ensure that we can always inspect whether a run is finished and get correct data
28
+ other.set_callback :perform, :after, :finish_staged_job, if: -> { was_staged_job? && !was_workflow_job? }
29
+ # We also allow you to write any of your own callbacks keyed to the "finish" event.
30
+ # The "finish" event is notably different from the "perform" event for acidic jobs,
31
+ # as any acidic job can have one or more "perform" event (retries or a resume after awaiting jobs),
32
+ # but will only ever have one "finish" event (when the run successfully completes)
33
+ other.define_callbacks :finish
34
+ end
35
+
36
+ included do
37
+ Mixin.wire_up(self)
38
+ end
39
+
40
+ class_methods do
41
+ def inherited(subclass)
42
+ Mixin.wire_up(subclass)
43
+ super
44
+ end
45
+
46
+ # `perform_now` runs a job synchronously and immediately
47
+ # `perform_later` runs a job asynchronously and queues it immediately
48
+ # `perform_acidicly` run a job asynchronously and queues it after a successful database commit
49
+ def perform_acidicly(*args)
50
+ job = new(*args)
51
+
52
+ Run.stage!(job)
53
+ end
54
+
55
+ # Instantiate an instance of a job ready for serialization
56
+ def with(...)
57
+ # New delegation syntax (...) was introduced in Ruby 2.7.
58
+ # https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
59
+ job = new(...)
60
+ # force the job to resolve the `queue_name`, so that we don't try to serialize a Proc into ActiveRecord
61
+ job.queue_name
62
+ job
63
+ end
64
+ end
65
+
66
+ def idempotency_key
67
+ IdempotencyKey.new(self).value(acidic_by: acidic_identifier)
68
+ end
69
+
70
+ protected
71
+
72
+ # Short circuits execution by sending execution right to 'finished'.
73
+ # So, ends the job "successfully"
74
+ def safely_finish_acidic_job
75
+ FinishedPoint.new
76
+ end
77
+
78
+ def with_acidic_workflow(persisting: {}, &block)
79
+ raise RedefiningWorkflow if defined? @workflow_builder
80
+
81
+ @workflow_builder = WorkflowBuilder.new
82
+
83
+ raise MissingWorkflowBlock, "A block must be passed to `with_acidic_workflow`" unless block_given?
84
+
85
+ if block.arity.zero?
86
+ @workflow_builder.instance_exec(&block)
87
+ else
88
+ yield @workflow_builder
89
+ end
90
+
91
+ raise NoDefinedSteps if @workflow_builder.steps.empty?
92
+
93
+ # convert the array of steps into a hash of recovery_points and next steps
94
+ workflow = @workflow_builder.define_workflow
95
+
96
+ ensure_run_record_and_process(workflow, persisting)
97
+ rescue LocalJumpError
98
+ raise MissingWorkflowBlock, "A block must be passed to `with_acidic_workflow`"
99
+ end
100
+
101
+ # DEPRECATED
102
+ # second attempt at the DSL, but `providing` suggested you needed to serialize
103
+ # any data that would be used in step methods, but simply instance variables work.
104
+ def with_acidity(providing: {}, &block)
105
+ ::ActiveSupport::Deprecation.new("1.0", "AcidicJob").deprecation_warning(:with_acidity)
106
+
107
+ @workflow_builder = WorkflowBuilder.new
108
+ @workflow_builder.instance_exec(&block)
109
+
110
+ raise NoDefinedSteps if @workflow_builder.steps.empty?
111
+
112
+ # convert the array of steps into a hash of recovery_points and next steps
113
+ workflow = @workflow_builder.define_workflow
114
+
115
+ ensure_run_record_and_process(workflow, providing)
116
+ rescue LocalJumpError
117
+ raise MissingWorkflowBlock, "A block must be passed to `with_acidity`"
118
+ end
119
+
120
+ # DEPRECATED
121
+ # first attempt at the DSL, but `idempotently` and `with` are too distant from the gem name.
122
+ def idempotently(with: {}, &block)
123
+ ::ActiveSupport::Deprecation.new("1.0", "AcidicJob").deprecation_warning(:idempotently)
124
+
125
+ @workflow_builder = WorkflowBuilder.new
126
+ @workflow_builder.instance_exec(&block)
127
+
128
+ raise NoDefinedSteps if @workflow_builder.steps.empty?
129
+
130
+ # convert the array of steps into a hash of recovery_points and next steps
131
+ workflow = @workflow_builder.define_workflow
132
+
133
+ ensure_run_record_and_process(workflow, with)
134
+ rescue LocalJumpError
135
+ raise MissingWorkflowBlock, "A block must be passed to `idempotently`"
136
+ end
137
+
138
+ private
139
+
140
+ # You can always retrieve the unique identifier from within the job instance itself
141
+ def acidic_identifier
142
+ self.class.instance_variable_get(:@acidic_identifier)
143
+ end
144
+
145
+ def ensure_run_record_and_process(workflow, persisting)
146
+ ::AcidicJob.logger.log_run_event("Initializing run...", self, nil)
147
+ @acidic_job_run = ::ActiveRecord::Base.transaction(isolation: acidic_isolation_level) do
148
+ run = Run.find_by(idempotency_key: idempotency_key)
149
+ serialized_job = serialize
150
+
151
+ if run.present?
152
+ # Programs enqueuing multiple jobs with different parameters but the
153
+ # same idempotency key is a bug.
154
+ if run.serialized_job["arguments"] != serialized_job["arguments"]
155
+ raise MismatchedIdempotencyKeyAndJobArguments
156
+ end
157
+
158
+ # Only acquire a lock if the key is unlocked or its lock has expired
159
+ # because the original job was long enough ago.
160
+ raise LockedIdempotencyKey if run.lock_active?
161
+
162
+ run.update!(
163
+ last_run_at: Time.current,
164
+ locked_at: Time.current,
165
+ # staged workflow jobs won't have the `workflow` or `recovery_point` stored when staged,
166
+ # so we need to persist both here.
167
+ workflow: workflow,
168
+ recovery_point: run.recovery_point || workflow.keys.first
169
+ )
170
+ else
171
+ run = Run.create!(
172
+ staged: false,
173
+ idempotency_key: idempotency_key,
174
+ job_class: self.class.name,
175
+ locked_at: Time.current,
176
+ last_run_at: Time.current,
177
+ workflow: workflow,
178
+ recovery_point: workflow.keys.first,
179
+ serialized_job: serialize
180
+ )
181
+ end
182
+
183
+ # persist `persisting` values and set accessors for each
184
+ # first, get the current state of all accessors for both previously persisted and initialized values
185
+ current_accessors = persisting.stringify_keys.merge(run.attr_accessors)
186
+
187
+ # next, ensure that `Run#attr_accessors` is populated with initial values
188
+ # skip validations for this call to ensure a write
189
+ run.update_column(:attr_accessors, current_accessors) if current_accessors != run.attr_accessors
190
+
191
+ # finally, set reader and writer methods
192
+ current_accessors.each do |accessor, value|
193
+ # the reader method may already be defined
194
+ self.class.attr_reader accessor unless respond_to?(accessor)
195
+ # but we should always update the value to match the current value
196
+ instance_variable_set("@#{accessor}", value)
197
+ # and we overwrite the setter to ensure any updates to an accessor update the `Run` stored value
198
+ # Note: we must define the singleton method on the instance to avoid overwriting setters on other
199
+ # instances of the same class
200
+ define_singleton_method("#{accessor}=") do |updated_value|
201
+ instance_variable_set("@#{accessor}", updated_value)
202
+ run.attr_accessors[accessor] = updated_value
203
+ run.save!(validate: false)
204
+ updated_value
205
+ end
206
+ end
207
+
208
+ # ensure that we return the `run` record so that the result of this block is the record
209
+ run
210
+ end
211
+ ::AcidicJob.logger.log_run_event("Initialized run.", self, @acidic_job_run)
212
+
213
+ Processor.new(@acidic_job_run, self).process_run
214
+ end
215
+
216
+ def was_staged_job?
217
+ job_id.start_with? Run::STAGED_JOB_ID_PREFIX
218
+ end
219
+
220
+ def was_workflow_job?
221
+ return false unless defined? @acidic_job_run
222
+
223
+ @acidic_job_run.present?
224
+ end
225
+
226
+ def staged_job_run
227
+ return unless was_staged_job?
228
+ # memoize so we don't have to make unnecessary database calls
229
+ return @staged_job_run if defined? @staged_job_run
230
+
231
+ # "STG__#{idempotency_key}__#{encoded_global_id}"
232
+ _prefix, _idempotency_key, encoded_global_id = job_id.split("__")
233
+ staged_job_gid = "gid://#{::Base64.decode64(encoded_global_id)}"
234
+
235
+ @staged_job_run = ::GlobalID::Locator.locate(staged_job_gid)
236
+ end
237
+
238
+ def finish_staged_job
239
+ staged_job_run.finish!
240
+ end
241
+
242
+ def acidic_isolation_level
243
+ case ::ActiveRecord::Base.connection.adapter_name.downcase.to_sym
244
+ # SQLite doesn't support `serializable` transactions,
245
+ # so we use the strictest isolation_level is does support
246
+ when :sqlite
247
+ :read_uncommitted
248
+ else
249
+ :serializable
250
+ end
251
+ end
252
+ end
253
+ end
@@ -1,35 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
+ # NOTE: it is essential that this be a bare module and not an ActiveSupport::Concern
5
+ # WHY?
4
6
  module PerformWrapper
5
7
  def perform(*args, **kwargs)
6
- # extract the `staged_job_gid` if present
7
- # so that we can later delete the record in an `after_perform` callback
8
- final_arg = args.last
9
- if final_arg.is_a?(Hash) && final_arg.key?("staged_job_gid")
10
- args = args[0..-2]
11
- @staged_job_gid = final_arg["staged_job_gid"]
12
- end
13
-
14
- set_arguments_for_perform(*args, **kwargs)
15
-
16
- super(*args, **kwargs)
17
- end
18
-
19
- private
8
+ @arguments = args
20
9
 
21
- def set_arguments_for_perform(*args, **kwargs)
22
- # store arguments passed into `perform` so that we can later persist
23
- # them to `AcidicJob::Key#job_args` for both ActiveJob and Sidekiq::Worker
24
- @arguments_for_perform = if args.any? && kwargs.any?
25
- args + [kwargs]
26
- elsif args.any? && kwargs.none?
27
- args
28
- elsif args.none? && kwargs.any?
29
- [kwargs]
30
- else
31
- []
32
- end
10
+ # we don't want to run the `perform` callbacks twice, since ActiveJob already handles that for us
11
+ if defined?(ActiveJob) && self.class < ActiveJob::Base
12
+ super(*args, **kwargs)
13
+ elsif defined?(Sidekiq) && self.class.include?(Sidekiq::Worker)
14
+ run_callbacks :perform do
15
+ super(*args, **kwargs)
16
+ end
17
+ else
18
+ raise UnknownJobAdapter
19
+ end
33
20
  end
34
21
  end
35
22
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class Processor
5
+ def initialize(run, job)
6
+ @run = run
7
+ @job = job
8
+ @workflow = Workflow.new(run, job)
9
+ end
10
+
11
+ def process_run
12
+ # if the run record is already marked as finished, immediately return its result
13
+ return @run.succeeded? if @run.finished?
14
+
15
+ AcidicJob.logger.log_run_event("Processing #{@run.current_step_name}...", @job, @run)
16
+ loop do
17
+ break if @run.finished?
18
+
19
+ if !@run.known_recovery_point?
20
+ raise UnknownRecoveryPoint,
21
+ "Defined workflow does not reference this step: #{@run.current_step_name.inspect}"
22
+ elsif !Array(awaited_jobs = @run.current_step_hash.fetch("awaits", [])).compact.empty?
23
+ # We only execute the current step, without progressing to the next step.
24
+ # This ensures that any failures in parallel jobs will have this step retried in the main workflow
25
+ step_result = @workflow.execute_current_step
26
+ # We allow the `#step_done` method to manage progressing the recovery_point to the next step,
27
+ # and then calling `process_run` to restart the main workflow on the next step.
28
+ # We pass the `step_result` so that the async callback called after the step-parallel-jobs complete
29
+ # can move on to the appropriate next stage in the workflow.
30
+ enqueue_awaited_jobs(awaited_jobs, step_result)
31
+ # after processing the current step, break the processing loop
32
+ # and stop this method from blocking in the primary worker
33
+ # as it will continue once the background workers all succeed
34
+ # so we want to keep the primary worker queue free to process new work
35
+ # this CANNOT ever be `break` as that wouldn't exit the parent job,
36
+ # only this step in the workflow, blocking as it awaits the next step
37
+ return true
38
+ else
39
+ @workflow.execute_current_step
40
+ @workflow.progress_to_next_step
41
+ end
42
+ end
43
+ AcidicJob.logger.log_run_event("Processed #{@run.current_step_name}.", @job, @run)
44
+
45
+ @run.succeeded?
46
+ end
47
+
48
+ private
49
+
50
+ def enqueue_awaited_jobs(jobs_or_jobs_getter, step_result)
51
+ awaited_jobs = jobs_from(jobs_or_jobs_getter)
52
+
53
+ AcidicJob.logger.log_run_event("Enqueuing #{awaited_jobs.count} awaited jobs...", @job, @run)
54
+ # All jobs created in the block are pushed atomically at the end of the block.
55
+ AcidicJob::Run.transaction do
56
+ awaited_jobs.each do |awaited_job|
57
+ worker_class, args = job_args_and_kwargs(awaited_job)
58
+
59
+ job = worker_class.new(*args)
60
+
61
+ AcidicJob::Run.await!(job, by: @run, return_to: step_result)
62
+ end
63
+ end
64
+ AcidicJob.logger.log_run_event("Enqueued #{awaited_jobs.count} awaited jobs.", @job, @run)
65
+ end
66
+
67
+ def jobs_from(jobs_or_jobs_getter)
68
+ case jobs_or_jobs_getter
69
+ when Array
70
+ jobs_or_jobs_getter
71
+ when Symbol, String
72
+ if @job.respond_to?(jobs_or_jobs_getter)
73
+ @job.method(jobs_or_jobs_getter).call
74
+ else
75
+ raise UnknownAwaitedJob,
76
+ "Invalid `awaits`; unknown method `#{jobs_or_jobs_getter}` for this job"
77
+ end
78
+ else
79
+ raise UnknownAwaitedJob,
80
+ "Invalid `awaits`; must be either an jobs Array or method name, was: #{jobs_or_jobs_getter.class.name}"
81
+ end
82
+ end
83
+
84
+ def job_args_and_kwargs(job)
85
+ case job
86
+ when Class
87
+ [job, []]
88
+ else
89
+ [
90
+ job.class,
91
+ job.arguments
92
+ ]
93
+ end
94
+ end
95
+ end
96
+ end