acidic_job 1.0.0.pre29 → 1.0.0.rc2

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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.codacy.yml +4 -0
  3. data/.github/FUNDING.yml +13 -0
  4. data/.github/workflows/main.yml +12 -15
  5. data/.gitignore +3 -1
  6. data/.rubocop.yml +50 -5
  7. data/.ruby-version +1 -0
  8. data/Gemfile.lock +134 -193
  9. data/README.md +164 -246
  10. data/TODO +77 -0
  11. data/acidic_job.gemspec +10 -10
  12. data/app/models/acidic_job/entry.rb +19 -0
  13. data/app/models/acidic_job/execution.rb +50 -0
  14. data/app/models/acidic_job/record.rb +11 -0
  15. data/app/models/acidic_job/value.rb +7 -0
  16. data/bin/console +5 -2
  17. data/bin/test_all +26 -0
  18. data/gemfiles/rails_7.0.gemfile +4 -1
  19. data/gemfiles/rails_7.1.gemfile +11 -0
  20. data/gemfiles/rails_7.2.gemfile +11 -0
  21. data/gemfiles/rails_8.0.gemfile +11 -0
  22. data/lib/acidic_job/arguments.rb +31 -0
  23. data/lib/acidic_job/builder.rb +29 -0
  24. data/lib/acidic_job/context.rb +46 -0
  25. data/lib/acidic_job/engine.rb +46 -0
  26. data/lib/acidic_job/errors.rb +87 -12
  27. data/lib/acidic_job/log_subscriber.rb +50 -0
  28. data/lib/acidic_job/serializers/exception_serializer.rb +31 -0
  29. data/lib/acidic_job/serializers/job_serializer.rb +27 -0
  30. data/lib/acidic_job/serializers/new_record_serializer.rb +25 -0
  31. data/lib/acidic_job/serializers/range_serializer.rb +28 -0
  32. data/lib/acidic_job/testing.rb +8 -12
  33. data/lib/acidic_job/version.rb +1 -1
  34. data/lib/acidic_job/workflow.rb +182 -0
  35. data/lib/acidic_job.rb +15 -284
  36. data/lib/generators/acidic_job/install_generator.rb +3 -3
  37. data/lib/generators/acidic_job/templates/create_acidic_job_tables_migration.rb.erb +33 -0
  38. metadata +51 -95
  39. data/.ruby_version +0 -1
  40. data/.tool-versions +0 -1
  41. data/gemfiles/rails_6.1.gemfile +0 -8
  42. data/lib/acidic_job/awaiting.rb +0 -102
  43. data/lib/acidic_job/extensions/action_mailer.rb +0 -29
  44. data/lib/acidic_job/extensions/active_job.rb +0 -40
  45. data/lib/acidic_job/extensions/noticed.rb +0 -54
  46. data/lib/acidic_job/extensions/sidekiq.rb +0 -111
  47. data/lib/acidic_job/finished_point.rb +0 -16
  48. data/lib/acidic_job/idempotency_key.rb +0 -82
  49. data/lib/acidic_job/perform_wrapper.rb +0 -22
  50. data/lib/acidic_job/recovery_point.rb +0 -18
  51. data/lib/acidic_job/rspec_configuration.rb +0 -31
  52. data/lib/acidic_job/run.rb +0 -100
  53. data/lib/acidic_job/serializer.rb +0 -163
  54. data/lib/acidic_job/staging.rb +0 -38
  55. data/lib/acidic_job/step.rb +0 -104
  56. data/lib/acidic_job/test_case.rb +0 -9
  57. data/lib/acidic_job/upgrade_service.rb +0 -118
  58. data/lib/generators/acidic_job/drop_tables_generator.rb +0 -26
  59. data/lib/generators/acidic_job/templates/create_acidic_job_runs_migration.rb.erb +0 -19
  60. data/lib/generators/acidic_job/templates/drop_acidic_job_keys_migration.rb.erb +0 -27
@@ -0,0 +1,27 @@
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
+ # don't serialize the `enqueued_at` value, as ActiveRecord will check if the Run record has changed
10
+ # by comparing the deserialized database value with a temporary in-memory generated value.
11
+ # That temporary in-memory generated value can sometimes have an `enqueued_at` value that is 1 second off
12
+ # from the original. In this case, ActiveRecord will think the record has unsaved changes and block the lock.
13
+ super(job.serialize.except("enqueued_at"))
14
+ end
15
+
16
+ def deserialize(hash)
17
+ job = ::ActiveJob::Base.deserialize(hash)
18
+ job.send(:deserialize_arguments_if_needed)
19
+ job
20
+ end
21
+
22
+ def serialize?(argument)
23
+ defined?(::ActiveJob::Base) && argument.class < ::ActiveJob::Base
24
+ end
25
+ end
26
+ end
27
+ end
@@ -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 NewRecordSerializer < ::ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(new_record)
9
+ super(
10
+ "class" => new_record.class.name,
11
+ "attributes" => new_record.attributes
12
+ )
13
+ end
14
+
15
+ def deserialize(hash)
16
+ new_record_class = hash["class"].constantize
17
+ new_record_class.new(hash["attributes"])
18
+ end
19
+
20
+ def serialize?(argument)
21
+ defined?(::ActiveRecord) && argument.respond_to?(:new_record?) && argument.new_record?
22
+ end
23
+ end
24
+ end
25
+ 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:
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_job/queue_adapters"
4
- require "active_job/base"
5
- require "active_job/test_helper"
6
- require "active_job/test_case"
7
3
  require "database_cleaner"
8
4
 
9
5
  module AcidicJob
@@ -13,17 +9,17 @@ module AcidicJob
13
9
  end
14
10
 
15
11
  def before_setup
16
- @connection = ActiveRecord::Base.connection
17
- @original_cleaners = DatabaseCleaner.cleaners
18
- DatabaseCleaner.cleaners = transaction_free_cleaners_for(@original_cleaners)
12
+ @connection = ::ActiveRecord::Base.connection
13
+ @original_cleaners = ::DatabaseCleaner.cleaners
14
+ ::DatabaseCleaner.cleaners = transaction_free_cleaners_for(@original_cleaners)
19
15
  super
20
- DatabaseCleaner.start
16
+ ::DatabaseCleaner.start
21
17
  end
22
18
 
23
19
  def after_teardown
24
- DatabaseCleaner.clean
20
+ ::DatabaseCleaner.clean
25
21
  super
26
- DatabaseCleaner.cleaners = @original_cleaners
22
+ ::DatabaseCleaner.cleaners = @original_cleaners
27
23
  end
28
24
 
29
25
  private
@@ -34,7 +30,7 @@ module AcidicJob
34
30
  non_transaction_cleaners = original_cleaners.dup.to_h do |(orm, opts), cleaner|
35
31
  [[orm, opts], ensure_no_transaction_strategies_for(cleaner)]
36
32
  end
37
- DatabaseCleaner::Cleaners.new(non_transaction_cleaners)
33
+ ::DatabaseCleaner::Cleaners.new(non_transaction_cleaners)
38
34
  end
39
35
 
40
36
  def ensure_no_transaction_strategies_for(cleaner)
@@ -56,7 +52,7 @@ module AcidicJob
56
52
 
57
53
  def deletion_strategy_for(cleaner)
58
54
  strategy = cleaner.strategy
59
- strategy_namespace = strategy # <DatabaseCleaner::ActiveRecord::Truncation>
55
+ strategy_namespace = strategy # <DatabaseCleaner::ActiveRecord::Truncation>
60
56
  .class # DatabaseCleaner::ActiveRecord::Truncation
61
57
  .name # "DatabaseCleaner::ActiveRecord::Truncation"
62
58
  .rpartition("::") # ["DatabaseCleaner::ActiveRecord", "::", "Truncation"]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
- VERSION = "1.0.0.pre29"
4
+ VERSION = "1.0.0.rc2"
5
5
  end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module AcidicJob
6
+ module Workflow
7
+ NO_OP_WRAPPER = proc { |&block| block.call }
8
+ REPEAT_STEP = :REPEAT_STEP
9
+ HALT_STEP = :HALT_STEP
10
+ private_constant :NO_OP_WRAPPER, :REPEAT_STEP, :HALT_STEP
11
+
12
+ attr_reader :execution, :ctx
13
+
14
+ def execute_workflow(unique_by:, &block)
15
+ serialized_job = serialize
16
+
17
+ workflow_definition = AcidicJob.instrument(:define_workflow, **serialized_job) do
18
+ raise RedefiningWorkflowError if defined? @_builder
19
+
20
+ @_builder = Builder.new
21
+
22
+ raise UndefinedWorkflowBlockError unless block_given?
23
+ raise InvalidWorkflowBlockError if block.arity != 1
24
+
25
+ block.call @_builder
26
+
27
+ raise MissingStepsError if @_builder.steps.empty?
28
+
29
+ # convert the array of steps into a hash of recovery_points and next steps
30
+ @_builder.define_workflow
31
+ end
32
+
33
+ AcidicJob.instrument(:initialize_workflow, "definition" => workflow_definition) do
34
+ transaction_args = case ::ActiveRecord::Base.connection.adapter_name.downcase.to_sym
35
+ # SQLite doesn't support `serializable` transactions
36
+ when :sqlite
37
+ {}
38
+ else
39
+ { isolation: :serializable }
40
+ end
41
+ idempotency_key = Digest::SHA256.hexdigest(JSON.fast_generate([self.class.name, unique_by], strict: true))
42
+
43
+ @execution = ::ActiveRecord::Base.transaction(**transaction_args) do
44
+ record = Execution.find_by(idempotency_key: idempotency_key)
45
+
46
+ if record.present?
47
+ # Programs enqueuing multiple jobs with different parameters but the
48
+ # same idempotency key is a bug.
49
+ if record.raw_arguments != serialized_job["arguments"]
50
+ raise ArgumentMismatchError.new(serialized_job["arguments"], record.raw_arguments)
51
+ end
52
+
53
+ if record.definition != workflow_definition
54
+ raise DefinitionMismatchError.new(workflow_definition, record.definition)
55
+ end
56
+
57
+ # Only acquire a lock if the key is unlocked or its lock has expired
58
+ # because the original job was long enough ago.
59
+ # raise "LockedIdempotencyKey" if record.locked_at > Time.current - 2.seconds
60
+
61
+ record.update!(
62
+ last_run_at: Time.current
63
+ )
64
+ else
65
+ record = Execution.create!(
66
+ idempotency_key: idempotency_key,
67
+ serialized_job: serialized_job,
68
+ definition: workflow_definition,
69
+ recover_to: workflow_definition.keys.first
70
+ )
71
+ end
72
+
73
+ record
74
+ end
75
+ end
76
+ @ctx ||= Context.new(@execution)
77
+
78
+ AcidicJob.instrument(:process_workflow, execution: @execution.attributes) do
79
+ # if the workflow record is already marked as finished, immediately return its result
80
+ return true if @execution.finished?
81
+
82
+ loop do
83
+ break if @execution.finished?
84
+
85
+ current_step = @execution.recover_to
86
+
87
+ if not @execution.definition.key?(current_step) # rubocop:disable Style/Not
88
+ raise UndefinedStepError.new(current_step)
89
+ end
90
+
91
+ step_definition = @execution.definition[current_step]
92
+ AcidicJob.instrument(:process_step, **step_definition) do
93
+ recover_to = catch(:halt) { take_step(step_definition) }
94
+ case recover_to
95
+ when HALT_STEP
96
+ @execution.record!(step: step_definition.fetch("does"), action: :halted, timestamp: Time.now)
97
+ return true
98
+ else
99
+ @execution.update!(recover_to: recover_to)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ def repeat_step!
107
+ throw :repeat, REPEAT_STEP
108
+ end
109
+
110
+ def halt_step!
111
+ throw :halt, HALT_STEP
112
+ end
113
+
114
+ def step_retrying?
115
+ step_name = caller_locations.first.label
116
+
117
+ if not @execution.definition.key?(step_name) # rubocop:disable Style/IfUnlessModifier, Style/Not
118
+ raise UndefinedStepError.new(step_name)
119
+ end
120
+
121
+ @execution.entries.where(step: step_name, action: "started").count > 1
122
+ end
123
+
124
+ private
125
+
126
+ def take_step(step_definition)
127
+ curr_step = step_definition.fetch("does")
128
+ next_step = step_definition.fetch("then")
129
+
130
+ return next_step if @execution.entries.exists?(step: curr_step, action: :succeeded)
131
+
132
+ rescued_error = nil
133
+ begin
134
+ @execution.record!(step: curr_step, action: :started, timestamp: Time.now)
135
+ result = AcidicJob.instrument(:perform_step, **step_definition) do
136
+ perform_step_for(step_definition)
137
+ end
138
+ case result
139
+ when REPEAT_STEP
140
+ curr_step
141
+ else
142
+ @execution.record!(step: curr_step, action: :succeeded, timestamp: Time.now, result: result)
143
+ next_step
144
+ end
145
+ rescue StandardError => e
146
+ rescued_error = e
147
+ raise e
148
+ ensure
149
+ if rescued_error
150
+ begin
151
+ @execution.record!(
152
+ step: curr_step,
153
+ action: :errored,
154
+ timestamp: Time.now,
155
+ exception_class: rescued_error.class.name,
156
+ message: rescued_error.message
157
+ )
158
+ rescue StandardError => e
159
+ # We're already inside an error condition, so swallow any additional
160
+ # errors from here and just send them to logs.
161
+ logger.error(
162
+ "Failed to store exception at step #{curr_step} for execution ##{@execution.id} because of #{e}."
163
+ )
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ def perform_step_for(step_definition)
170
+ step_name = step_definition.fetch("does")
171
+ step_method = method(step_name)
172
+
173
+ raise InvalidMethodError.new(step_name) unless step_method.arity.zero?
174
+
175
+ wrapper = step_definition["transactional"] ? @execution.method(:with_lock) : NO_OP_WRAPPER
176
+
177
+ catch(:repeat) { wrapper.call { step_method.call } }
178
+ rescue NameError
179
+ raise UndefinedMethodError.new(step_name)
180
+ end
181
+ end
182
+ end
data/lib/acidic_job.rb CHANGED
@@ -1,297 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "acidic_job/version"
4
+ require_relative "acidic_job/engine"
4
5
  require_relative "acidic_job/errors"
5
- require_relative "acidic_job/recovery_point"
6
- require_relative "acidic_job/finished_point"
7
- require_relative "acidic_job/run"
8
- require_relative "acidic_job/step"
9
- require_relative "acidic_job/staging"
10
- require_relative "acidic_job/awaiting"
11
- require_relative "acidic_job/perform_wrapper"
12
- require_relative "acidic_job/idempotency_key"
13
- require_relative "acidic_job/extensions/sidekiq"
14
- require_relative "acidic_job/extensions/action_mailer"
15
- require_relative "acidic_job/extensions/active_job"
16
- require_relative "acidic_job/extensions/noticed"
17
- require_relative "acidic_job/upgrade_service"
6
+ require_relative "acidic_job/builder"
7
+ require_relative "acidic_job/context"
8
+ require_relative "acidic_job/arguments"
9
+ require_relative "acidic_job/log_subscriber"
10
+ require_relative "acidic_job/workflow"
18
11
 
19
- require "active_support/concern"
20
- require "active_job/queue_adapters"
21
- require "active_job/base"
12
+ require "active_support"
22
13
 
23
14
  module AcidicJob
24
- extend ActiveSupport::Concern
15
+ extend self
25
16
 
26
- IDEMPOTENCY_KEY_LOCK_TIMEOUT = 90
17
+ DEFAULT_LOGGER = ActiveSupport::Logger.new($stdout)
18
+ FINISHED_RECOVERY_POINT = "FINISHED"
27
19
 
28
- def self.wire_everything_up(klass)
29
- # Ensure our `perform` method always runs first to gather parameters
30
- klass.prepend PerformWrapper
20
+ mattr_accessor :logger, default: DEFAULT_LOGGER
21
+ mattr_accessor :connects_to
31
22
 
32
- klass.include Staging
33
- klass.include Awaiting
34
-
35
- # Add `deliver_acidicly` to ActionMailer
36
- ActionMailer::Parameterized::MessageDelivery.include Extensions::ActionMailer if defined?(ActionMailer)
37
- # Add `deliver_acidicly` to Noticed
38
- Noticed::Base.include Extensions::Noticed if defined?(Noticed)
39
-
40
- if defined?(ActiveJob) && klass < ActiveJob::Base
41
- klass.send(:include, Extensions::ActiveJob)
42
- elsif defined?(Sidekiq) && klass.include?(Sidekiq::Worker)
43
- klass.send(:include, Extensions::Sidekiq)
44
- klass.include ActiveSupport::Callbacks
45
- klass.define_callbacks :perform
46
- else
47
- raise UnknownJobAdapter
48
- end
49
-
50
- # TODO: write test for a staged job that uses awaits
51
- klass.set_callback :perform, :after, :reenqueue_awaited_by_job,
52
- if: -> { was_awaited_job? && !was_workflow_job? }
53
- klass.set_callback :perform, :after, :finish_staged_job, if: -> { was_staged_job? && !was_workflow_job? }
54
- klass.define_callbacks :finish
55
- klass.set_callback :finish, :after, :reenqueue_awaited_by_job,
56
- if: -> { was_workflow_job? && was_awaited_job? }
57
-
58
- klass.instance_variable_set(:@acidic_identifier, :job_id)
59
- klass.define_singleton_method(:acidic_by_job_id) { @acidic_identifier = :job_id }
60
- klass.define_singleton_method(:acidic_by_job_args) { @acidic_identifier = :job_args }
61
- klass.define_singleton_method(:acidic_by) { |proc| @acidic_identifier = proc }
62
- klass.attr_reader(:acidic_job_run)
63
- end
64
-
65
- included do
66
- AcidicJob.wire_everything_up(self)
67
- end
68
-
69
- class_methods do
70
- def inherited(subclass)
71
- AcidicJob.wire_everything_up(subclass)
72
- super
73
- end
74
-
75
- def with(*args, **kwargs)
76
- new(*args, **kwargs)
77
- end
78
-
79
- def acidic_identifier
80
- @acidic_identifier
81
- end
82
- end
83
-
84
- def initialize(*args, **kwargs)
85
- # ensure this instance variable is always defined
86
- @__acidic_job_steps = []
87
- @__acidic_job_args = args
88
- @__acidic_job_kwargs = kwargs
89
-
90
- super(*args, **kwargs)
91
- rescue ArgumentError => e
92
- raise e unless e.message.include?("wrong number of arguments")
93
-
94
- super()
95
- end
96
-
97
- def with_acidity(providing: {})
98
- # execute the block to gather the info on what steps are defined for this job workflow
99
- yield
100
-
101
- # check that the block actually defined at least one step
102
- # TODO: WRITE TESTS FOR FAULTY BLOCK VALUES
103
- raise NoDefinedSteps if @__acidic_job_steps.nil? || @__acidic_job_steps.empty?
104
-
105
- # convert the array of steps into a hash of recovery_points and next steps
106
- workflow = define_workflow(@__acidic_job_steps)
107
-
108
- @acidic_job_run = ensure_run_record(workflow, providing)
109
-
110
- # begin the workflow
111
- process_run(@acidic_job_run)
112
- end
113
-
114
- # DEPRECATED
115
- def idempotently(with: {}, &blk)
116
- ActiveSupport::Deprecation.new("1.0", "AcidicJob").deprecation_warning(:idempotently)
117
- with_acidity(providing: with, &blk)
118
- end
119
-
120
- def safely_finish_acidic_job
121
- # Short circuits execution by sending execution right to 'finished'.
122
- # So, ends the job "successfully"
123
- FinishedPoint.new
124
- end
125
-
126
- # rubocop:disable Naming/MemoizedInstanceVariableName
127
- def idempotency_key
128
- if defined?(@__acidic_job_idempotency_key) && !@__acidic_job_idempotency_key.nil?
129
- return @__acidic_job_idempotency_key
130
- end
131
-
132
- acidic_identifier = self.class.acidic_identifier
133
- @__acidic_job_idempotency_key ||= IdempotencyKey.new(acidic_identifier)
134
- .value_for(self, *@__acidic_job_args, **@__acidic_job_kwargs)
135
- end
136
- # rubocop:enable Naming/MemoizedInstanceVariableName
137
-
138
- private
139
-
140
- def finish_staged_job
141
- FinishedPoint.new.call(run: staged_job_run)
142
- end
143
-
144
- def was_workflow_job?
145
- defined?(@acidic_job_run) && @acidic_job_run.present?
146
- end
147
-
148
- def process_run(run)
149
- # if the run record is already marked as finished, immediately return its result
150
- return run.succeeded? if run.finished?
151
-
152
- # otherwise, we will enter a loop to process each step of the workflow
153
- loop do
154
- recovery_point = run.recovery_point.to_s
155
- current_step = run.workflow[recovery_point]
156
-
157
- # if any step calls `safely_finish_acidic_job` or the workflow has simply completed,
158
- # be sure to break out of the loop
159
- if recovery_point.to_s == Run::FINISHED_RECOVERY_POINT.to_s # rubocop:disable Style/GuardClause
160
- break
161
- elsif current_step.nil?
162
- raise UnknownRecoveryPoint, "Defined workflow does not reference this step: #{recovery_point}"
163
- elsif !Array(jobs = current_step.fetch("awaits", []) || []).compact.empty?
164
- step = Step.new(current_step, run, self)
165
- # Only execute the current step, without yet progressing the recovery_point to the next step.
166
- # This ensures that any failures in parallel jobs will have this step retried in the main workflow
167
- step_result = step.execute
168
- # We allow the `#step_done` method to manage progressing the recovery_point to the next step,
169
- # and then calling `process_run` to restart the main workflow on the next step.
170
- # We pass the `step_result` so that the async callback called after the step-parallel-jobs complete
171
- # can move on to the appropriate next stage in the workflow.
172
- enqueue_step_parallel_jobs(jobs, run, step_result)
173
- # after processing the current step, break the processing loop
174
- # and stop this method from blocking in the primary worker
175
- # as it will continue once the background workers all succeed
176
- # so we want to keep the primary worker queue free to process new work
177
- # this CANNOT ever be `break` as that wouldn't exit the parent job,
178
- # only this step in the workflow, blocking as it awaits the next step
179
- return true
180
- else
181
- step = Step.new(current_step, run, self)
182
- step.execute
183
- # As this step does not await any parallel jobs, we can immediately progress to the next step
184
- step.progress
185
- end
186
- end
187
-
188
- # the loop will break once the job is finished, so simply report the status
189
- run.succeeded?
190
- end
191
-
192
- def step(method_name, awaits: [], for_each: nil)
193
- @__acidic_job_steps ||= []
194
-
195
- @__acidic_job_steps << {
196
- "does" => method_name.to_s,
197
- "awaits" => awaits,
198
- "for_each" => for_each
199
- }
200
-
201
- @__acidic_job_steps
202
- end
203
-
204
- def define_workflow(steps)
205
- # [ { does: "step 1", awaits: [] }, { does: "step 2", awaits: [] }, ... ]
206
- steps << { "does" => Run::FINISHED_RECOVERY_POINT }
207
-
208
- {}.tap do |workflow|
209
- steps.each_cons(2).map do |enter_step, exit_step|
210
- enter_name = enter_step["does"]
211
- workflow[enter_name] = enter_step.merge("then" => exit_step["does"])
212
- end
213
- end
214
- # { "step 1": { does: "step 1", awaits: [], then: "step 2" }, ... }
215
- end
216
-
217
- def ensure_run_record(workflow, accessors)
218
- isolation_level = case ActiveRecord::Base.connection.adapter_name.downcase.to_sym
219
- when :sqlite
220
- :read_uncommitted
221
- else
222
- :serializable
223
- end
224
-
225
- ActiveRecord::Base.transaction(isolation: isolation_level) do
226
- run = Run.find_by(idempotency_key: idempotency_key)
227
- serialized_job = serialize_job(*@__acidic_job_args, **@__acidic_job_kwargs)
228
-
229
- if run.present?
230
- # Programs enqueuing multiple jobs with different parameters but the
231
- # same idempotency key is a bug.
232
- if run.serialized_job.slice("args", "arguments") != serialized_job.slice("args", "arguments")
233
- raise MismatchedIdempotencyKeyAndJobArguments
234
- end
235
-
236
- # Only acquire a lock if the key is unlocked or its lock has expired
237
- # because the original job was long enough ago.
238
- raise LockedIdempotencyKey if run.locked_at && run.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
239
-
240
- # Lock the run and update latest run unless the job is already finished.
241
- unless run.finished?
242
- run.update!(
243
- last_run_at: Time.current,
244
- locked_at: Time.current,
245
- workflow: workflow,
246
- recovery_point: run.recovery_point || workflow.first.first
247
- )
248
- end
249
- else
250
- run = Run.create!(
251
- staged: false,
252
- idempotency_key: idempotency_key,
253
- job_class: self.class.name,
254
- locked_at: Time.current,
255
- last_run_at: Time.current,
256
- recovery_point: workflow.first.first,
257
- workflow: workflow,
258
- serialized_job: serialized_job
259
- )
260
- end
261
-
262
- # set accessors for each argument passed in to ensure they are available
263
- # to the step methods the job will have written
264
- define_accessors_for_passed_arguments(accessors, run)
265
-
266
- # NOTE: we must return the `key` object from this transaction block
267
- # so that it can be returned from this method to the caller
268
- run
269
- end
23
+ def instrument(channel, **options, &block)
24
+ ActiveSupport::Notifications.instrument("#{channel}.acidic_job", **options, &block)
270
25
  end
271
26
 
272
- def define_accessors_for_passed_arguments(passed_arguments, run)
273
- # first, get the current state of all accessors for both previously persisted and initialized values
274
- current_accessors = passed_arguments.stringify_keys.merge(run.attr_accessors)
275
-
276
- # next, ensure that `Run#attr_accessors` is populated with initial values
277
- run.update_column(:attr_accessors, current_accessors)
278
-
279
- current_accessors.each do |accessor, value|
280
- # the reader method may already be defined
281
- self.class.attr_reader accessor unless respond_to?(accessor)
282
- # but we should always update the value to match the current value
283
- instance_variable_set("@#{accessor}", value)
284
- # and we overwrite the setter to ensure any updates to an accessor update the `Key` stored value
285
- # Note: we must define the singleton method on the instance to avoid overwriting setters on other
286
- # instances of the same class
287
- define_singleton_method("#{accessor}=") do |current_value|
288
- instance_variable_set("@#{accessor}", current_value)
289
- run.attr_accessors[accessor] = current_value
290
- run.save!(validate: false)
291
- current_value
292
- end
293
- end
294
-
295
- true
296
- end
27
+ ActiveSupport.run_load_hooks(:acidic_job, self)
297
28
  end
@@ -8,12 +8,12 @@ module AcidicJob
8
8
  include ActiveRecord::Generators::Migration
9
9
  source_root File.expand_path("templates", __dir__)
10
10
 
11
- desc "Generates a migration for the AcidicJob::Run table."
11
+ desc "Generates a migration for the AcidicJob tables."
12
12
 
13
13
  # Copies the migration template to db/migrate.
14
14
  def copy_acidic_job_runs_migration_files
15
- migration_template "create_acidic_job_runs_migration.rb.erb",
16
- "db/migrate/create_acidic_job_runs.rb",
15
+ migration_template "create_acidic_job_tables_migration.rb.erb",
16
+ "db/migrate/create_acidic_job_tables.rb",
17
17
  migration_version: migration_version
18
18
  end
19
19
 
@@ -0,0 +1,33 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :acidic_job_executions, force: true do |t|
4
+ t.string :idempotency_key, null: false, index: { unique: true }
5
+ t.json :serialized_job, null: false, default: "{}"
6
+ t.datetime :last_run_at, null: true
7
+ t.datetime :locked_at, null: true
8
+ t.string :recover_to, null: true
9
+ t.json :definition, null: true, default: "{}"
10
+ t.timestamps
11
+ end
12
+
13
+ create_table :acidic_job_entries do |t|
14
+ t.references :execution, null: false, foreign_key: { to_table: :acidic_job_executions }
15
+ t.string :step, null: false
16
+ t.string :action, null: false
17
+ t.datetime :timestamp, null: false
18
+ t.json :data
19
+
20
+ t.timestamps
21
+ end
22
+ add_index :acidic_job_entries, [:execution_id, :step]
23
+
24
+ create_table :acidic_job_values do |t|
25
+ t.references :execution, null: false, foreign_key: { to_table: :acidic_job_executions }
26
+ t.string :key, null: false
27
+ t.json :value, null: false, default: "{}"
28
+
29
+ t.timestamps
30
+ end
31
+ add_index :acidic_job_values, [:execution_id, :key], unique: true
32
+ end
33
+ end