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.
- checksums.yaml +4 -4
- data/.codacy.yml +4 -0
- data/.github/FUNDING.yml +13 -0
- data/.github/workflows/main.yml +12 -15
- data/.gitignore +3 -1
- data/.rubocop.yml +50 -5
- data/.ruby-version +1 -0
- data/Gemfile.lock +134 -193
- data/README.md +164 -246
- data/TODO +77 -0
- data/acidic_job.gemspec +10 -10
- data/app/models/acidic_job/entry.rb +19 -0
- data/app/models/acidic_job/execution.rb +50 -0
- data/app/models/acidic_job/record.rb +11 -0
- data/app/models/acidic_job/value.rb +7 -0
- data/bin/console +5 -2
- data/bin/test_all +26 -0
- data/gemfiles/rails_7.0.gemfile +4 -1
- data/gemfiles/rails_7.1.gemfile +11 -0
- data/gemfiles/rails_7.2.gemfile +11 -0
- data/gemfiles/rails_8.0.gemfile +11 -0
- data/lib/acidic_job/arguments.rb +31 -0
- data/lib/acidic_job/builder.rb +29 -0
- data/lib/acidic_job/context.rb +46 -0
- data/lib/acidic_job/engine.rb +46 -0
- data/lib/acidic_job/errors.rb +87 -12
- data/lib/acidic_job/log_subscriber.rb +50 -0
- data/lib/acidic_job/serializers/exception_serializer.rb +31 -0
- data/lib/acidic_job/serializers/job_serializer.rb +27 -0
- data/lib/acidic_job/serializers/new_record_serializer.rb +25 -0
- data/lib/acidic_job/serializers/range_serializer.rb +28 -0
- data/lib/acidic_job/testing.rb +8 -12
- data/lib/acidic_job/version.rb +1 -1
- data/lib/acidic_job/workflow.rb +182 -0
- data/lib/acidic_job.rb +15 -284
- data/lib/generators/acidic_job/install_generator.rb +3 -3
- data/lib/generators/acidic_job/templates/create_acidic_job_tables_migration.rb.erb +33 -0
- metadata +51 -95
- data/.ruby_version +0 -1
- data/.tool-versions +0 -1
- data/gemfiles/rails_6.1.gemfile +0 -8
- data/lib/acidic_job/awaiting.rb +0 -102
- data/lib/acidic_job/extensions/action_mailer.rb +0 -29
- data/lib/acidic_job/extensions/active_job.rb +0 -40
- data/lib/acidic_job/extensions/noticed.rb +0 -54
- data/lib/acidic_job/extensions/sidekiq.rb +0 -111
- data/lib/acidic_job/finished_point.rb +0 -16
- data/lib/acidic_job/idempotency_key.rb +0 -82
- data/lib/acidic_job/perform_wrapper.rb +0 -22
- data/lib/acidic_job/recovery_point.rb +0 -18
- data/lib/acidic_job/rspec_configuration.rb +0 -31
- data/lib/acidic_job/run.rb +0 -100
- data/lib/acidic_job/serializer.rb +0 -163
- data/lib/acidic_job/staging.rb +0 -38
- data/lib/acidic_job/step.rb +0 -104
- data/lib/acidic_job/test_case.rb +0 -9
- data/lib/acidic_job/upgrade_service.rb +0 -118
- data/lib/generators/acidic_job/drop_tables_generator.rb +0 -26
- data/lib/generators/acidic_job/templates/create_acidic_job_runs_migration.rb.erb +0 -19
- 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:
|
data/lib/acidic_job/testing.rb
CHANGED
@@ -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
|
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"]
|
data/lib/acidic_job/version.rb
CHANGED
@@ -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/
|
6
|
-
require_relative "acidic_job/
|
7
|
-
require_relative "acidic_job/
|
8
|
-
require_relative "acidic_job/
|
9
|
-
require_relative "acidic_job/
|
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
|
20
|
-
require "active_job/queue_adapters"
|
21
|
-
require "active_job/base"
|
12
|
+
require "active_support"
|
22
13
|
|
23
14
|
module AcidicJob
|
24
|
-
extend
|
15
|
+
extend self
|
25
16
|
|
26
|
-
|
17
|
+
DEFAULT_LOGGER = ActiveSupport::Logger.new($stdout)
|
18
|
+
FINISHED_RECOVERY_POINT = "FINISHED"
|
27
19
|
|
28
|
-
|
29
|
-
|
30
|
-
klass.prepend PerformWrapper
|
20
|
+
mattr_accessor :logger, default: DEFAULT_LOGGER
|
21
|
+
mattr_accessor :connects_to
|
31
22
|
|
32
|
-
|
33
|
-
|
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
|
-
|
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
|
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 "
|
16
|
-
"db/migrate/
|
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
|