acidic_job 1.0.0.beta.10 → 1.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +12 -36
  3. data/.gitignore +0 -5
  4. data/.ruby_version +1 -0
  5. data/Gemfile +31 -0
  6. data/Gemfile.lock +130 -136
  7. data/README.md +58 -278
  8. data/acidic_job.gemspec +2 -15
  9. data/bin/console +2 -4
  10. data/lib/acidic_job/awaiting.rb +68 -0
  11. data/lib/acidic_job/errors.rb +19 -11
  12. data/lib/acidic_job/extensions/action_mailer.rb +11 -3
  13. data/lib/acidic_job/extensions/active_job.rb +39 -0
  14. data/lib/acidic_job/extensions/noticed.rb +11 -5
  15. data/lib/acidic_job/extensions/sidekiq.rb +101 -0
  16. data/lib/acidic_job/finished_point.rb +5 -3
  17. data/lib/acidic_job/idempotency_key.rb +15 -18
  18. data/lib/acidic_job/perform_wrapper.rb +36 -9
  19. data/lib/acidic_job/recovery_point.rb +3 -2
  20. data/lib/acidic_job/run.rb +42 -268
  21. data/lib/acidic_job/staging.rb +30 -0
  22. data/lib/acidic_job/step.rb +83 -0
  23. data/lib/acidic_job/version.rb +1 -1
  24. data/lib/acidic_job.rb +244 -20
  25. data/lib/generators/acidic_job_generator.rb +35 -0
  26. data/lib/generators/templates/create_acidic_job_runs_migration.rb.erb +19 -0
  27. metadata +15 -209
  28. data/.github/FUNDING.yml +0 -13
  29. data/.tool-versions +0 -1
  30. data/UPGRADE_GUIDE.md +0 -81
  31. data/combustion/log/test.log +0 -0
  32. data/gemfiles/rails_6.1_sidekiq_6.4.gemfile +0 -10
  33. data/gemfiles/rails_6.1_sidekiq_6.5.gemfile +0 -10
  34. data/gemfiles/rails_7.0_sidekiq_6.4.gemfile +0 -10
  35. data/gemfiles/rails_7.0_sidekiq_6.5.gemfile +0 -10
  36. data/gemfiles/rails_7.1_sidekiq_6.4.gemfile +0 -10
  37. data/gemfiles/rails_7.1_sidekiq_6.5.gemfile +0 -10
  38. data/lib/acidic_job/active_kiq.rb +0 -114
  39. data/lib/acidic_job/arguments.rb +0 -22
  40. data/lib/acidic_job/base.rb +0 -11
  41. data/lib/acidic_job/logger.rb +0 -31
  42. data/lib/acidic_job/mixin.rb +0 -250
  43. data/lib/acidic_job/processor.rb +0 -95
  44. data/lib/acidic_job/rails.rb +0 -40
  45. data/lib/acidic_job/serializer.rb +0 -24
  46. data/lib/acidic_job/serializers/exception_serializer.rb +0 -41
  47. data/lib/acidic_job/serializers/finished_point_serializer.rb +0 -24
  48. data/lib/acidic_job/serializers/job_serializer.rb +0 -27
  49. data/lib/acidic_job/serializers/range_serializer.rb +0 -28
  50. data/lib/acidic_job/serializers/recovery_point_serializer.rb +0 -25
  51. data/lib/acidic_job/serializers/worker_serializer.rb +0 -27
  52. data/lib/acidic_job/test_case.rb +0 -9
  53. data/lib/acidic_job/testing.rb +0 -73
  54. data/lib/acidic_job/workflow.rb +0 -70
  55. data/lib/acidic_job/workflow_builder.rb +0 -35
  56. data/lib/acidic_job/workflow_step.rb +0 -103
  57. data/lib/generators/acidic_job/drop_tables_generator.rb +0 -26
  58. data/lib/generators/acidic_job/install_generator.rb +0 -27
  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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ # Each AcidicJob::Step requires two phases: [1] execution and [2] progression
5
+ class Step
6
+ def initialize(step, run, job, step_result = nil)
7
+ @step = step
8
+ @run = run
9
+ @job = job
10
+ @step_result = step_result
11
+ end
12
+
13
+ # The execution phase performs the work of the defined step
14
+ def execute
15
+ rescued_error = false
16
+ step_callable = wrap_step_as_acidic_callable @step
17
+
18
+ begin
19
+ @run.with_lock do
20
+ @step_result = step_callable.call(@run)
21
+ end
22
+ # QUESTION: Can an error not inherit from StandardError
23
+ rescue StandardError => e
24
+ rescued_error = e
25
+ raise e
26
+ ensure
27
+ if rescued_error
28
+ # If we're leaving under an error condition, try to unlock the job
29
+ # run right away so that another request can try again.
30
+ begin
31
+ @run.update_columns(locked_at: nil, error_object: rescued_error)
32
+ rescue StandardError => e
33
+ # We're already inside an error condition, so swallow any additional
34
+ # errors from here and just send them to logs.
35
+ # TODO: implement and use a logger here
36
+ puts "Failed to unlock AcidicJob::Run #{@run.id} because of #{e}."
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ # The progression phase advances the job run state machine onto the next step
43
+ def progress
44
+ @run.with_lock do
45
+ @step_result.call(run: @run)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def wrap_step_as_acidic_callable(step)
52
+ # {"does" => :enqueue_step, "then" => :next_step, "awaits" => [WorkerWithEnqueueStep::FirstWorker]}
53
+ current_step = step["does"]
54
+ next_step = step["then"]
55
+
56
+ # jobs can have no-op steps, especially so that they can use only the async/await mechanism for that step
57
+ callable = if @job.respond_to?(current_step, _include_private = true)
58
+ @job.method(current_step)
59
+ else
60
+ proc {}
61
+ end
62
+
63
+ # return a callable Proc with a consistent interface for the execution phase
64
+ proc do |run|
65
+ result = if callable.arity.zero?
66
+ callable.call
67
+ elsif callable.arity == 1
68
+ callable.call(run)
69
+ else
70
+ raise TooManyParametersForStepMethod
71
+ end
72
+
73
+ if result.is_a?(FinishedPoint)
74
+ result
75
+ elsif next_step.to_s == Run::FINISHED_RECOVERY_POINT
76
+ FinishedPoint.new
77
+ else
78
+ RecoveryPoint.new(next_step)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
- VERSION = "1.0.0.beta.10"
4
+ VERSION = "1.0.0.pre1"
5
5
  end
data/lib/acidic_job.rb CHANGED
@@ -2,33 +2,257 @@
2
2
 
3
3
  require_relative "acidic_job/version"
4
4
  require_relative "acidic_job/errors"
5
- require_relative "acidic_job/logger"
6
- require_relative "acidic_job/arguments"
7
- require_relative "acidic_job/serializer"
8
- require_relative "acidic_job/workflow_builder"
9
- require_relative "acidic_job/idempotency_key"
10
5
  require_relative "acidic_job/recovery_point"
11
6
  require_relative "acidic_job/finished_point"
12
7
  require_relative "acidic_job/run"
13
- require_relative "acidic_job/workflow_step"
14
- require_relative "acidic_job/workflow"
15
- require_relative "acidic_job/processor"
8
+ require_relative "acidic_job/step"
9
+ require_relative "acidic_job/staging"
10
+ require_relative "acidic_job/awaiting"
16
11
  require_relative "acidic_job/perform_wrapper"
12
+ require_relative "acidic_job/idempotency_key"
13
+ require_relative "acidic_job/extensions/sidekiq"
17
14
  require_relative "acidic_job/extensions/action_mailer"
15
+ require_relative "acidic_job/extensions/active_job"
18
16
  require_relative "acidic_job/extensions/noticed"
19
- require_relative "acidic_job/mixin"
20
- require_relative "acidic_job/base"
21
- # require_relative "acidic_job/active_kiq"
22
-
23
- require "active_job/serializers"
24
- require_relative "acidic_job/serializers/exception_serializer"
25
- require_relative "acidic_job/serializers/finished_point_serializer"
26
- require_relative "acidic_job/serializers/job_serializer"
27
- require_relative "acidic_job/serializers/range_serializer"
28
- require_relative "acidic_job/serializers/recovery_point_serializer"
29
- require_relative "acidic_job/serializers/worker_serializer"
30
17
 
31
- require_relative "acidic_job/rails"
18
+ require "active_support/concern"
32
19
 
33
20
  module AcidicJob
21
+ extend ActiveSupport::Concern
22
+
23
+ IDEMPOTENCY_KEY_LOCK_TIMEOUT = 90
24
+
25
+ def self.wire_everything_up(klass)
26
+ # Ensure our `perform` method always runs first to gather parameters
27
+ klass.prepend PerformWrapper
28
+
29
+ klass.include Staging
30
+ klass.include Awaiting
31
+
32
+ # Add `deliver_acidicly` to ActionMailer
33
+ ActionMailer::Parameterized::MessageDelivery.include Extensions::ActionMailer if defined?(ActionMailer)
34
+ # Add `deliver_acidicly` to Noticed
35
+ Noticed::Base.include Extensions::Noticed if defined?(Noticed)
36
+
37
+ if defined?(ActiveJob) && klass < ActiveJob::Base
38
+ klass.send(:include, Extensions::ActiveJob)
39
+ elsif defined?(Sidekiq) && klass.include?(Sidekiq::Worker)
40
+ klass.send(:include, Extensions::Sidekiq)
41
+ klass.include ActiveSupport::Callbacks
42
+ klass.define_callbacks :perform
43
+ else
44
+ raise UnknownJobAdapter
45
+ end
46
+
47
+ klass.set_callback :perform, :after, :delete_staged_job_record, if: :was_staged_job?
48
+ end
49
+
50
+ included do
51
+ AcidicJob.wire_everything_up(self)
52
+ end
53
+
54
+ class_methods do
55
+ def inherited(subclass)
56
+ AcidicJob.wire_everything_up(subclass)
57
+ super
58
+ end
59
+ end
60
+
61
+ def with_acidity(given: {})
62
+ # execute the block to gather the info on what steps are defined for this job workflow
63
+ @__acidic_job_steps = []
64
+ steps = yield || []
65
+
66
+ # check that the block actually defined at least one step
67
+ # TODO: WRITE TESTS FOR FAULTY BLOCK VALUES
68
+ raise NoDefinedSteps if @__acidic_job_steps.nil? || @__acidic_job_steps.empty?
69
+
70
+ # convert the array of steps into a hash of recovery_points and next steps
71
+ workflow = define_workflow(steps)
72
+
73
+ # determine the idempotency key value for this job run (`job_id` or `jid`)
74
+ # might be defined already in `identifier` method
75
+ # TODO: allow idempotency to be defined by args OR job id
76
+ @__acidic_job_idempotency_key ||= IdempotencyKey.value_for(self, @__acidic_job_args, @__acidic_job_kwargs)
77
+
78
+ @run = ensure_run_record(@__acidic_job_idempotency_key, workflow, given)
79
+
80
+ # begin the workflow
81
+ process_run(@run)
82
+ end
83
+
84
+ # DEPRECATED
85
+ def idempotently(with:, &blk)
86
+ with_acidity(given: with, &blk)
87
+ end
88
+
89
+ def safely_finish_acidic_job
90
+ # Short circuits execution by sending execution right to 'finished'.
91
+ # So, ends the job "successfully"
92
+ FinishedPoint.new
93
+ end
94
+
95
+ # rubocop:disable Naming/MemoizedInstanceVariableName
96
+ def idempotency_key
97
+ return @__acidic_job_idempotency_key if defined? @__acidic_job_idempotency_key
98
+
99
+ @__acidic_job_idempotency_key ||= IdempotencyKey.value_for(self, @__acidic_job_args, @__acidic_job_kwargs)
100
+ end
101
+ # rubocop:enable Naming/MemoizedInstanceVariableName
102
+
103
+ private
104
+
105
+ def process_run(run)
106
+ # if the run record is already marked as finished, immediately return its result
107
+ return run.succeeded? if run.finished?
108
+
109
+ # otherwise, we will enter a loop to process each step of the workflow
110
+ run.workflow.size.times do
111
+ recovery_point = run.recovery_point.to_s
112
+ current_step = run.workflow[recovery_point]
113
+
114
+ # if any step calls `safely_finish_acidic_job` or the workflow has simply completed,
115
+ # be sure to break out of the loop
116
+ if recovery_point == Run::FINISHED_RECOVERY_POINT.to_s # rubocop:disable Style/GuardClause
117
+ break
118
+ elsif current_step.nil?
119
+ raise UnknownRecoveryPoint, "Defined workflow does not reference this step: #{recovery_point}"
120
+ elsif (jobs = current_step.fetch("awaits", [])).any?
121
+ step = Step.new(current_step, run, self)
122
+ # Only execute the current step, without yet progressing the recovery_point to the next step.
123
+ # This ensures that any failures in parallel jobs will have this step retried in the main workflow
124
+ step_result = step.execute
125
+ # We allow the `#step_done` method to manage progressing the recovery_point to the next step,
126
+ # and then calling `process_run` to restart the main workflow on the next step.
127
+ # We pass the `step_result` so that the async callback called after the step-parallel-jobs complete
128
+ # can move on to the appropriate next stage in the workflow.
129
+ enqueue_step_parallel_jobs(jobs, run, step_result)
130
+ # after processing the current step, break the processing loop
131
+ # and stop this method from blocking in the primary worker
132
+ # as it will continue once the background workers all succeed
133
+ # so we want to keep the primary worker queue free to process new work
134
+ # this CANNOT ever be `break` as that wouldn't exit the parent job,
135
+ # only this step in the workflow, blocking as it awaits the next step
136
+ return true
137
+ else
138
+ step = Step.new(current_step, run, self)
139
+ step.execute
140
+ # As this step does not await any parallel jobs, we can immediately progress to the next step
141
+ step.progress
142
+ end
143
+ end
144
+
145
+ # the loop will break once the job is finished, so simply report the status
146
+ run.succeeded?
147
+ end
148
+
149
+ def step(method_name, awaits: [])
150
+ @__acidic_job_steps ||= []
151
+
152
+ @__acidic_job_steps << {
153
+ "does" => method_name.to_s,
154
+ "awaits" => awaits
155
+ }
156
+
157
+ @__acidic_job_steps
158
+ end
159
+
160
+ def define_workflow(steps)
161
+ # [ { does: "step 1", awaits: [] }, { does: "step 2", awaits: [] }, ... ]
162
+ steps << { "does" => Run::FINISHED_RECOVERY_POINT }
163
+
164
+ {}.tap do |workflow|
165
+ steps.each_cons(2).map do |enter_step, exit_step|
166
+ enter_name = enter_step["does"]
167
+ workflow[enter_name] = enter_step.merge("then" => exit_step["does"])
168
+ end
169
+ end
170
+ # { "step 1": { does: "step 1", awaits: [], then: "step 2" }, ... }
171
+ end
172
+
173
+ def ensure_run_record(key_val, workflow, accessors)
174
+ isolation_level = case ActiveRecord::Base.connection.adapter_name.downcase.to_sym
175
+ when :sqlite
176
+ :read_uncommitted
177
+ else
178
+ :serializable
179
+ end
180
+
181
+ ActiveRecord::Base.transaction(isolation: isolation_level) do
182
+ run = Run.find_by(idempotency_key: key_val)
183
+ serialized_job = serialize_job(@__acidic_job_args, @__acidic_job_kwargs)
184
+
185
+ if run.present?
186
+ # Programs enqueuing multiple jobs with different parameters but the
187
+ # same idempotency key is a bug.
188
+ # NOTE: WOULD THE ENQUEUED_AT OR CREATED_AT FIELD BE NECESSARILY DIFFERENT?
189
+ if run.serialized_job.except("jid", "job_id",
190
+ "enqueued_at") != serialized_job.except("jid", "job_id", "enqueued_at")
191
+ raise MismatchedIdempotencyKeyAndJobArguments
192
+ end
193
+
194
+ # Only acquire a lock if the key is unlocked or its lock has expired
195
+ # because the original job was long enough ago.
196
+ raise LockedIdempotencyKey if run.locked_at && run.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
197
+
198
+ # Lock the run and update latest run unless the job is already finished.
199
+ run.update!(last_run_at: Time.current, locked_at: Time.current, workflow: workflow) unless run.finished?
200
+ else
201
+ run = Run.create!(
202
+ staged: false,
203
+ idempotency_key: key_val,
204
+ job_class: self.class.name,
205
+ locked_at: Time.current,
206
+ last_run_at: Time.current,
207
+ recovery_point: workflow.first.first,
208
+ workflow: workflow,
209
+ serialized_job: serialized_job
210
+ )
211
+ end
212
+
213
+ # set accessors for each argument passed in to ensure they are available
214
+ # to the step methods the job will have written
215
+ define_accessors_for_passed_arguments(accessors, run)
216
+
217
+ # NOTE: we must return the `key` object from this transaction block
218
+ # so that it can be returned from this method to the caller
219
+ run
220
+ end
221
+ end
222
+
223
+ def define_accessors_for_passed_arguments(passed_arguments, run)
224
+ # first, get the current state of all accessors for both previously persisted and initialized values
225
+ current_accessors = passed_arguments.stringify_keys.merge(run.attr_accessors)
226
+
227
+ # next, ensure that `Run#attr_accessors` is populated with initial values
228
+ run.update_column(:attr_accessors, current_accessors)
229
+
230
+ current_accessors.each do |accessor, value|
231
+ # the reader method may already be defined
232
+ self.class.attr_reader accessor unless respond_to?(accessor)
233
+ # but we should always update the value to match the current value
234
+ instance_variable_set("@#{accessor}", value)
235
+ # and we overwrite the setter to ensure any updates to an accessor update the `Key` stored value
236
+ # Note: we must define the singleton method on the instance to avoid overwriting setters on other
237
+ # instances of the same class
238
+ define_singleton_method("#{accessor}=") do |current_value|
239
+ instance_variable_set("@#{accessor}", current_value)
240
+ run.attr_accessors[accessor] = current_value
241
+ run.save!(validate: false)
242
+ current_value
243
+ end
244
+ end
245
+
246
+ true
247
+ end
248
+
249
+ def identifier
250
+ return jid if defined?(jid) && !jid.nil?
251
+ return job_id if defined?(job_id) && !job_id.nil?
252
+
253
+ # might be defined already in `with_acidity` method
254
+ @__acidic_job_idempotency_key ||= IdempotencyKey.value_for(self, @__acidic_job_args, @__acidic_job_kwargs)
255
+
256
+ @__acidic_job_idempotency_key
257
+ end
34
258
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ class AcidicJobGenerator < ActiveRecord::Generators::Base
7
+ # ActiveRecord::Generators::Base inherits from Rails::Generators::NamedBase
8
+ # which requires a NAME parameter for the new table name.
9
+ # Our generator always uses "acidic_job_runs", so we just set a random name here.
10
+ argument :name, type: :string, default: "random_name"
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ def self.next_migration_number(_path)
15
+ if instance_variable_defined?("@prev_migration_nr") # :nocov:
16
+ @prev_migration_nr += 1
17
+ else
18
+ @prev_migration_nr = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
19
+ end
20
+
21
+ @prev_migration_nr.to_s
22
+ end
23
+
24
+ # Copies the migration template to db/migrate.
25
+ def copy_acidic_job_runs_migration_files
26
+ migration_template "create_acidic_job_runs_migration.rb.erb",
27
+ "db/migrate/create_acidic_job_runs.rb"
28
+ end
29
+
30
+ protected
31
+
32
+ def migration_class
33
+ ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"]
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ class CreateAcidicJobKeys < <%= migration_class %>
2
+ def change
3
+ create_table :acidic_job_runs, force: true do |t|
4
+ t.boolean :staged, null: false, default: -> { false }
5
+ t.string :idempotency_key, null: false
6
+ t.text :serialized_job, null: false
7
+ t.string :job_class, null: false
8
+ t.datetime :last_run_at, null: true, default: -> { "CURRENT_TIMESTAMP" }
9
+ t.datetime :locked_at, null: true
10
+ t.string :recovery_point, null: true
11
+ t.text :error_object, null: true
12
+ t.text :attr_accessors, null: true
13
+ t.text :workflow, null: true
14
+ t.timestamps
15
+
16
+ t.index :idempotency_key, unique: true
17
+ end
18
+ end
19
+ end