acidic_job 1.0.0.pre28 → 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +13 -0
  3. data/.github/workflows/main.yml +12 -15
  4. data/.gitignore +3 -1
  5. data/.rubocop.yml +50 -5
  6. data/.ruby-version +1 -0
  7. data/Gemfile.lock +114 -198
  8. data/README.md +163 -246
  9. data/TODO +77 -0
  10. data/acidic_job.gemspec +8 -10
  11. data/app/models/acidic_job/entry.rb +19 -0
  12. data/app/models/acidic_job/execution.rb +50 -0
  13. data/app/models/acidic_job/record.rb +11 -0
  14. data/app/models/acidic_job/value.rb +7 -0
  15. data/bin/console +5 -2
  16. data/bin/test_all +26 -0
  17. data/gemfiles/rails_7.0.gemfile +4 -1
  18. data/gemfiles/rails_7.1.gemfile +11 -0
  19. data/gemfiles/rails_7.2.gemfile +11 -0
  20. data/gemfiles/rails_8.0.gemfile +11 -0
  21. data/lib/acidic_job/arguments.rb +31 -0
  22. data/lib/acidic_job/builder.rb +29 -0
  23. data/lib/acidic_job/context.rb +26 -0
  24. data/lib/acidic_job/engine.rb +46 -0
  25. data/lib/acidic_job/errors.rb +91 -12
  26. data/lib/acidic_job/log_subscriber.rb +50 -0
  27. data/lib/acidic_job/serializers/exception_serializer.rb +31 -0
  28. data/lib/acidic_job/serializers/job_serializer.rb +27 -0
  29. data/lib/acidic_job/serializers/new_record_serializer.rb +25 -0
  30. data/lib/acidic_job/serializers/range_serializer.rb +28 -0
  31. data/lib/acidic_job/testing.rb +8 -12
  32. data/lib/acidic_job/version.rb +1 -1
  33. data/lib/acidic_job/workflow.rb +185 -0
  34. data/lib/acidic_job.rb +15 -284
  35. data/lib/generators/acidic_job/install_generator.rb +3 -3
  36. data/lib/generators/acidic_job/templates/create_acidic_job_tables_migration.rb.erb +33 -0
  37. metadata +45 -115
  38. data/.ruby_version +0 -1
  39. data/.tool-versions +0 -1
  40. data/gemfiles/rails_6.1.gemfile +0 -8
  41. data/lib/acidic_job/awaiting.rb +0 -98
  42. data/lib/acidic_job/extensions/action_mailer.rb +0 -29
  43. data/lib/acidic_job/extensions/active_job.rb +0 -40
  44. data/lib/acidic_job/extensions/noticed.rb +0 -54
  45. data/lib/acidic_job/extensions/sidekiq.rb +0 -111
  46. data/lib/acidic_job/finished_point.rb +0 -16
  47. data/lib/acidic_job/idempotency_key.rb +0 -82
  48. data/lib/acidic_job/perform_wrapper.rb +0 -22
  49. data/lib/acidic_job/recovery_point.rb +0 -18
  50. data/lib/acidic_job/rspec_configuration.rb +0 -31
  51. data/lib/acidic_job/run.rb +0 -100
  52. data/lib/acidic_job/serializer.rb +0 -163
  53. data/lib/acidic_job/staging.rb +0 -38
  54. data/lib/acidic_job/step.rb +0 -104
  55. data/lib/acidic_job/test_case.rb +0 -9
  56. data/lib/acidic_job/upgrade_service.rb +0 -118
  57. data/lib/generators/acidic_job/drop_tables_generator.rb +0 -26
  58. data/lib/generators/acidic_job/templates/create_acidic_job_runs_migration.rb.erb +0 -19
  59. data/lib/generators/acidic_job/templates/drop_acidic_job_keys_migration.rb.erb +0 -27
@@ -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.pre28"
4
+ VERSION = "1.0.0.rc1"
5
5
  end
@@ -0,0 +1,185 @@
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.dump([self.class.name, unique_by]))
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
+ step_method = performable_step_for(step_definition)
133
+ rescued_error = nil
134
+ begin
135
+ @execution.record!(step: curr_step, action: :started, timestamp: Time.now)
136
+ result = AcidicJob.instrument(:perform_step, **step_definition) do
137
+ step_method.call
138
+ end
139
+ case result
140
+ when REPEAT_STEP
141
+ curr_step
142
+ else
143
+ @execution.record!(step: curr_step, action: :succeeded, timestamp: Time.now, result: result)
144
+ next_step
145
+ end
146
+ rescue StandardError => e
147
+ rescued_error = e
148
+ raise e
149
+ ensure
150
+ if rescued_error
151
+ begin
152
+ @execution.record!(
153
+ step: curr_step,
154
+ action: :errored,
155
+ timestamp: Time.now,
156
+ exception_class: rescued_error.class.name,
157
+ message: rescued_error.message
158
+ )
159
+ rescue StandardError => e
160
+ # We're already inside an error condition, so swallow any additional
161
+ # errors from here and just send them to logs.
162
+ logger.error(
163
+ "Failed to store exception at step #{curr_step} for execution ##{@execution.id} because of #{e}."
164
+ )
165
+ end
166
+ end
167
+ end
168
+ end
169
+
170
+ def performable_step_for(step_definition)
171
+ step_name = step_definition.fetch("does")
172
+ step_method = method(step_name)
173
+
174
+ raise InvalidMethodError.new(step_name) unless step_method.arity.zero?
175
+
176
+ wrapper = step_definition["transactional"] ? @execution.method(:with_lock) : NO_OP_WRAPPER
177
+
178
+ proc do
179
+ catch(:repeat) { wrapper.call { step_method.call } }
180
+ end
181
+ rescue NameError
182
+ raise UndefinedMethodError.new(step_name)
183
+ end
184
+ end
185
+ 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