acidic_job 0.7.7 → 1.0.0.pre3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +21 -0
  4. data/Gemfile +6 -0
  5. data/Gemfile.lock +99 -3
  6. data/README.md +47 -14
  7. data/UPGRADE_GUIDE.md +71 -0
  8. data/acidic_job.gemspec +2 -2
  9. data/bin/console +3 -2
  10. data/lib/acidic_job/awaiting.rb +68 -0
  11. data/lib/acidic_job/errors.rb +2 -0
  12. data/lib/acidic_job/extensions/action_mailer.rb +27 -0
  13. data/lib/acidic_job/extensions/active_job.rb +39 -0
  14. data/lib/acidic_job/extensions/noticed.rb +52 -0
  15. data/lib/acidic_job/extensions/sidekiq.rb +101 -0
  16. data/lib/acidic_job/{response.rb → finished_point.rb} +4 -4
  17. data/lib/acidic_job/idempotency_key.rb +24 -0
  18. data/lib/acidic_job/perform_wrapper.rb +34 -20
  19. data/lib/acidic_job/recovery_point.rb +3 -3
  20. data/lib/acidic_job/run.rb +77 -0
  21. data/lib/acidic_job/staging.rb +30 -0
  22. data/lib/acidic_job/step.rb +83 -0
  23. data/lib/acidic_job/upgrade_service.rb +115 -0
  24. data/lib/acidic_job/version.rb +1 -1
  25. data/lib/acidic_job.rb +121 -205
  26. data/lib/generators/acidic_job/drop_tables_generator.rb +31 -0
  27. data/lib/generators/acidic_job_generator.rb +5 -24
  28. data/lib/generators/templates/create_acidic_job_runs_migration.rb.erb +19 -0
  29. data/lib/generators/templates/{create_acidic_job_keys_migration.rb.erb → drop_acidic_job_keys_migration.rb.erb} +10 -3
  30. metadata +23 -17
  31. data/lib/acidic_job/deliver_transactionally_extension.rb +0 -26
  32. data/lib/acidic_job/key.rb +0 -33
  33. data/lib/acidic_job/no_op.rb +0 -11
  34. data/lib/acidic_job/perform_transactionally_extension.rb +0 -33
  35. data/lib/acidic_job/sidekiq_callbacks.rb +0 -45
  36. data/lib/acidic_job/staged.rb +0 -50
  37. data/lib/generators/templates/create_staged_acidic_jobs_migration.rb.erb +0 -10
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ module Extensions
5
+ module Noticed
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def deliver_acidicly(recipients)
10
+ new.deliver_acidicly(recipients)
11
+ end
12
+ end
13
+
14
+ def deliver_acidicly(recipients)
15
+ # THIS IS A HACK THAT COPIES AND PASTES KEY PARTS OF THE `Noticed::Base` CODE
16
+ # IN ORDER TO ALLOW US TO TRANSACTIONALLY DELIVER NOTIFICATIONS
17
+ # THIS IS THUS LIABLE TO BREAK WHENEVER THAT GEM IS UPDATED
18
+ delivery_methods = self.class.delivery_methods.dup
19
+
20
+ Array.wrap(recipients).uniq.each do |recipient|
21
+ if (index = delivery_methods.find_index { |m| m[:name] == :database })
22
+ database_delivery_method = delivery_methods.delete_at(index)
23
+ self.record = run_delivery_method(database_delivery_method,
24
+ recipient: recipient,
25
+ enqueue: false,
26
+ record: nil)
27
+ end
28
+
29
+ delivery_methods.map do |delivery_method|
30
+ job_class = delivery_method_for(delivery_method[:name], delivery_method[:options])
31
+ args = {
32
+ notification_class: self.class.name,
33
+ options: delivery_method[:options],
34
+ params: params,
35
+ recipient: recipient,
36
+ record: record
37
+ }
38
+ serialized_job = job_class.send(:job_or_instantiate, args).serialize
39
+
40
+ AcidicJob::Run.create!(
41
+ staged: true,
42
+ job_class: job_class.name,
43
+ serialized_job: serialized_job,
44
+ idempotency_key: IdempotencyKey.value_for(serialized_job)
45
+ )
46
+ end
47
+ end
48
+ end
49
+ alias deliver_transactionally deliver_acidicly
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/callbacks"
5
+ require "active_support/core_ext/module/concerning"
6
+
7
+ module AcidicJob
8
+ module Extensions
9
+ module Sidekiq
10
+ extend ActiveSupport::Concern
11
+
12
+ concerning :Serialization do
13
+ class_methods do
14
+ # called only from `AcidicJob::Run#enqueue_staged_job`
15
+ def deserialize(serialized_job_hash)
16
+ klass = serialized_job_hash["class"].constantize
17
+ worker = klass.new
18
+ worker.jid = serialized_job_hash["jid"]
19
+ worker.instance_variable_set(:@args, serialized_job_hash["args"])
20
+
21
+ worker
22
+ end
23
+
24
+ # called only from `AcidicJob::PerformAcidicly#perform_acidicly`
25
+ # and `AcidicJob::DeliverAcidicly#deliver_acidicly`
26
+ def serialize_with_arguments(args = [], _kwargs = nil)
27
+ # THIS IS A HACK THAT ESSENTIALLY COPIES THE CODE FROM THE SIDEKIQ CODEBASE TO MIMIC THE BEHAVIOR
28
+ args = Array[args]
29
+ normalized_args = ::Sidekiq.load_json(::Sidekiq.dump_json(args))
30
+ item = { "class" => self, "args" => normalized_args }
31
+ dummy_sidekiq_client = ::Sidekiq::Client.new
32
+ normed = dummy_sidekiq_client.send :normalize_item, item
33
+ dummy_sidekiq_client.send :process_single, item["class"], normed
34
+ end
35
+ end
36
+
37
+ def serialize_job(args = [], _kwargs = nil)
38
+ # `@args` is only set via `deserialize`; it is not a standard Sidekiq thing
39
+ arguments = args || @args
40
+ normalized_args = ::Sidekiq.load_json(::Sidekiq.dump_json(arguments))
41
+ item = { "class" => self.class, "args" => normalized_args, "jid" => jid }
42
+ sidekiq_options = sidekiq_options_hash || {}
43
+
44
+ sidekiq_options.merge(item)
45
+ end
46
+
47
+ # called only from `AcidicJob::Run#enqueue_staged_job`
48
+ def enqueue
49
+ ::Sidekiq::Client.push(
50
+ "class" => self.class,
51
+ "args" => @args,
52
+ "jid" => @jid
53
+ )
54
+ end
55
+ end
56
+
57
+ concerning :PerformAcidicly do
58
+ class_methods do
59
+ def perform_acidicly(*args, **kwargs)
60
+ serialized_job = serialize_with_arguments(*args, **kwargs)
61
+
62
+ AcidicJob::Run.create!(
63
+ staged: true,
64
+ job_class: name,
65
+ serialized_job: serialized_job,
66
+ idempotency_key: IdempotencyKey.value_for(serialized_job)
67
+ )
68
+ end
69
+ alias_method :perform_transactionally, :perform_acidicly
70
+ end
71
+ end
72
+
73
+ # to balance `perform_async` class method
74
+ concerning :PerformSync do
75
+ class_methods do
76
+ def perform_sync(*args, **kwargs)
77
+ new.perform(*args, **kwargs)
78
+ end
79
+ end
80
+ end
81
+
82
+ # Following approach used by ActiveJob
83
+ # https://github.com/rails/rails/blob/93c9534c9871d4adad4bc33b5edc355672b59c61/activejob/lib/active_job/callbacks.rb
84
+ concerning :Callbacks do
85
+ class_methods do
86
+ def around_perform(*filters, &blk)
87
+ set_callback(:perform, :around, *filters, &blk)
88
+ end
89
+
90
+ def before_perform(*filters, &blk)
91
+ set_callback(:perform, :before, *filters, &blk)
92
+ end
93
+
94
+ def after_perform(*filters, &blk)
95
+ set_callback(:perform, :after, *filters, &blk)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -4,12 +4,12 @@
4
4
  # idempotency key). One possible option for a return from an #atomic_phase
5
5
  # block.
6
6
  module AcidicJob
7
- class Response
8
- def call(key:)
7
+ class FinishedPoint
8
+ def call(run:)
9
9
  # Skip AR callbacks as there are none on the model
10
- key.update_columns(
10
+ run.update_columns(
11
11
  locked_at: nil,
12
- recovery_point: Key::RECOVERY_POINT_FINISHED
12
+ recovery_point: Run::FINISHED_RECOVERY_POINT
13
13
  )
14
14
  end
15
15
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class IdempotencyKey
5
+ def self.value_for(hash_or_job, *args, **kwargs)
6
+ return hash_or_job.job_id if hash_or_job.respond_to?(:job_id) && !hash_or_job.job_id.nil?
7
+ return hash_or_job.jid if hash_or_job.respond_to?(:jid) && !hash_or_job.jid.nil?
8
+
9
+ if hash_or_job.is_a?(Hash) && hash_or_job.key?("job_id") && !hash_or_job["job_id"].nil?
10
+ return hash_or_job["job_id"]
11
+ end
12
+ return hash_or_job["jid"] if hash_or_job.is_a?(Hash) && hash_or_job.key?("jid") && !hash_or_job["jid"].nil?
13
+
14
+ worker_class = case hash_or_job
15
+ when Hash
16
+ hash_or_job["worker"] || hash_or_job["job_class"]
17
+ else
18
+ hash_or_job.class.name
19
+ end
20
+
21
+ Digest::SHA1.hexdigest [worker_class, args, kwargs].flatten.join
22
+ end
23
+ end
24
+ end
@@ -1,35 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
+ # NOTE: it is essential that this be a bare module and not an ActiveSupport::Concern
4
5
  module PerformWrapper
5
6
  def perform(*args, **kwargs)
6
- # extract the `staged_job_gid` if present
7
- # so that we can later delete the record in an `after_perform` callback
8
- final_arg = args.last
9
- if final_arg.is_a?(Hash) && final_arg.key?("staged_job_gid")
10
- args = args[0..-2]
11
- @staged_job_gid = final_arg["staged_job_gid"]
7
+ super_method = method(:perform).super_method
8
+
9
+ # we don't want to run the `perform` callbacks twice, since ActiveJob already handles that for us
10
+ if aj_job?
11
+ __acidic_job_perform_for_aj(super_method, *args, **kwargs)
12
+ elsif sk_job?
13
+ __acidic_job_perform_for_sk(super_method, *args, **kwargs)
14
+ else
15
+ raise UnknownJobAdapter
12
16
  end
17
+ end
13
18
 
14
- set_arguments_for_perform(*args, **kwargs)
19
+ def sk_job?
20
+ defined?(Sidekiq) && self.class.include?(Sidekiq::Worker)
21
+ end
15
22
 
16
- super(*args, **kwargs)
23
+ def aj_job?
24
+ defined?(ActiveJob) && self.class < ActiveJob::Base
17
25
  end
18
26
 
19
27
  private
20
28
 
21
- def set_arguments_for_perform(*args, **kwargs)
22
- # store arguments passed into `perform` so that we can later persist
23
- # them to `AcidicJob::Key#job_args` for both ActiveJob and Sidekiq::Worker
24
- @arguments_for_perform = if args.any? && kwargs.any?
25
- args + [kwargs]
26
- elsif args.any? && kwargs.none?
27
- args
28
- elsif args.none? && kwargs.any?
29
- [kwargs]
30
- else
31
- []
32
- end
29
+ # don't run `perform` callbacks, as ActiveJob already does this
30
+ def __acidic_job_perform_for_aj(super_method, *args, **kwargs)
31
+ __acidic_job_perform_base(super_method, *args, **kwargs)
32
+ end
33
+
34
+ # ensure to run `perform` callbacks
35
+ def __acidic_job_perform_for_sk(super_method, *args, **kwargs)
36
+ run_callbacks :perform do
37
+ __acidic_job_perform_base(super_method, *args, **kwargs)
38
+ end
39
+ end
40
+
41
+ # capture arguments passed to `perform` to be used by AcidicJob later
42
+ def __acidic_job_perform_base(super_method, *args, **kwargs)
43
+ @__acidic_job_args = args
44
+ @__acidic_job_kwargs = kwargs
45
+
46
+ super_method.call(*args, **kwargs)
33
47
  end
34
48
  end
35
49
  end
@@ -7,12 +7,12 @@ module AcidicJob
7
7
  attr_accessor :name
8
8
 
9
9
  def initialize(name)
10
- self.name = name
10
+ @name = name
11
11
  end
12
12
 
13
- def call(key:)
13
+ def call(run:)
14
14
  # Skip AR callbacks as there are none on the model
15
- key.update_column(:recovery_point, name)
15
+ run.update_column(:recovery_point, @name)
16
16
  end
17
17
  end
18
18
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "global_id"
5
+ require "active_support/core_ext/object/with_options"
6
+
7
+ module AcidicJob
8
+ class Run < ActiveRecord::Base
9
+ include GlobalID::Identification
10
+
11
+ FINISHED_RECOVERY_POINT = "FINISHED"
12
+
13
+ self.table_name = "acidic_job_runs"
14
+
15
+ after_create_commit :enqueue_staged_job, if: :staged?
16
+
17
+ serialize :error_object
18
+ serialize :serialized_job
19
+ serialize :workflow
20
+ store :attr_accessors
21
+
22
+ validates :staged, inclusion: { in: [true, false] } # uses database default
23
+ validates :serialized_job, presence: true
24
+ validates :idempotency_key, presence: true, uniqueness: true
25
+ validates :job_class, presence: true
26
+
27
+ scope :staged, -> { where(staged: true) }
28
+ scope :unstaged, -> { where(staged: false) }
29
+ scope :finished, -> { where(recovery_point: FINISHED_RECOVERY_POINT) }
30
+ scope :running, -> { where.not(recovery_point: FINISHED_RECOVERY_POINT) }
31
+
32
+ with_options unless: :staged? do
33
+ validates :last_run_at, presence: true
34
+ validates :recovery_point, presence: true
35
+ validates :workflow, presence: true
36
+ end
37
+
38
+ def finished?
39
+ recovery_point == FINISHED_RECOVERY_POINT
40
+ end
41
+
42
+ def succeeded?
43
+ finished? && !failed?
44
+ end
45
+
46
+ def failed?
47
+ error_object.present?
48
+ end
49
+
50
+ private
51
+
52
+ def enqueue_staged_job
53
+ return unless staged?
54
+
55
+ # encode the identifier for this record in the job ID
56
+ # base64 encoding for minimal security
57
+ global_id = to_global_id.to_s.remove("gid://")
58
+ encoded_global_id = Base64.encode64(global_id).strip
59
+ staged_job_id = "STG_#{idempotency_key}__#{encoded_global_id}"
60
+
61
+ serialized_staged_job = if serialized_job.key?("jid")
62
+ serialized_job.merge("jid" => staged_job_id)
63
+ elsif serialized_job.key?("job_id")
64
+ serialized_job.merge("job_id" => staged_job_id)
65
+ else
66
+ raise UnknownSerializedJobIdentifier
67
+ end
68
+
69
+ job = job_class.constantize.deserialize(serialized_staged_job)
70
+
71
+ job.enqueue
72
+
73
+ # NOTE: record will be deleted after the job has successfully been performed
74
+ true
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module AcidicJob
6
+ module Staging
7
+ extend ActiveSupport::Concern
8
+
9
+ def delete_staged_job_record
10
+ return unless was_staged_job?
11
+
12
+ staged_job_run.delete
13
+ true
14
+ rescue ActiveRecord::RecordNotFound
15
+ true
16
+ end
17
+
18
+ def was_staged_job?
19
+ identifier.start_with? "STG_"
20
+ end
21
+
22
+ def staged_job_run
23
+ # "STG_#{idempotency_key}__#{encoded_global_id}"
24
+ encoded_global_id = identifier.split("__").last
25
+ staged_job_gid = "gid://#{Base64.decode64(encoded_global_id)}"
26
+
27
+ GlobalID::Locator.locate(staged_job_gid)
28
+ end
29
+ end
30
+ end
@@ -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
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module AcidicJob
6
+ # recreate the original `Key` model
7
+ class Key < ::ActiveRecord::Base
8
+ RECOVERY_POINT_FINISHED = "FINISHED"
9
+
10
+ self.table_name = "acidic_job_keys"
11
+
12
+ serialize :error_object
13
+ serialize :job_args
14
+ serialize :workflow
15
+ store :attr_accessors
16
+ end
17
+
18
+ # recreate the original `Staged` model
19
+ class Staged < ActiveRecord::Base
20
+ self.table_name = "staged_acidic_jobs"
21
+
22
+ serialize :job_args
23
+
24
+ after_create_commit :enqueue_job
25
+
26
+ private
27
+
28
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
29
+ def enqueue_job
30
+ gid = { "staged_job_gid" => to_global_id.to_s }
31
+
32
+ if job_args.is_a?(Hash) && job_args.key?("arguments")
33
+ job_args["arguments"].concat([gid])
34
+ else
35
+ job_args.concat([gid])
36
+ end
37
+
38
+ case adapter
39
+ when "activejob"
40
+ ::ActiveJob::Base.deserialize(job_args).enqueue
41
+ when "sidekiq"
42
+ job_name.constantize.perform_async(*job_args)
43
+ else
44
+ raise UnknownJobAdapter.new(adapter: adapter)
45
+ end
46
+
47
+ # NOTE: record will be deleted after the job has successfully been performed
48
+ true
49
+ end
50
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
51
+ end
52
+
53
+ module UpgradeService
54
+ def self.execute()
55
+ # prepare an array to hold the attribute hashes to be passed to `insert_all`
56
+ run_attributes = []
57
+ # prepare an array to hold any `Key` records that we couldn't successfully map to `Run` records
58
+ errored_keys = []
59
+
60
+ # iterate over all `AcidicJob::Key` records in batches, preparing a `Run` attribute hash to be passed to `insert_all`
61
+ ::AcidicJob::Key.find_each do |key|
62
+ # map all of the simple attributes directly
63
+ attributes = {
64
+ id: key.id,
65
+ staged: false,
66
+ idempotency_key: key.idempotency_key,
67
+ job_class: key.job_name,
68
+ last_run_at: key.last_run_at,
69
+ locked_at: key.locked_at,
70
+ recovery_point: key.recovery_point,
71
+ error_object: key.error_object,
72
+ attr_accessors: key.attr_accessors,
73
+ workflow: key.workflow,
74
+ created_at: key.created_at,
75
+ updated_at: key.updated_at
76
+ }
77
+
78
+ # prepare the more complicated `job_args` -> `serialized_job` translation
79
+ job_class = key.job_name.constantize
80
+ if defined?(::Sidekiq) && job_class.include?(::Sidekiq::Worker)
81
+ job_class.include(::AcidicJob::Extensions::Sidekiq) unless job_class.include?(::AcidicJob::Extensions::Sidekiq)
82
+ job_instance = job_class.new
83
+ serialized_job = job_instance.serialize_job(*key.job_args)
84
+ elsif defined?(::ActiveJob) && job_class < ::ActiveJob::Base
85
+ job_class.include(::AcidicJob::Extensions::ActiveJob) unless job_class.include?(::AcidicJob::Extensions::ActiveJob)
86
+ job_args = begin
87
+ ::ActiveJob::Arguments.deserialize(key.job_args)
88
+ rescue ::ActiveJob::DeserializationError
89
+ key.job_args
90
+ end
91
+ job_instance = job_class.new(*job_args)
92
+ serialized_job = job_instance.serialize_job()
93
+ end
94
+
95
+ attributes[:serialized_job] = serialized_job
96
+ run_attributes << attributes
97
+ rescue StandardError => exception
98
+ errored_keys << [exception, key]
99
+ end
100
+
101
+ # insert all of the `Run` records
102
+ ::AcidicJob::Run.insert_all(run_attributes)
103
+
104
+ # delete all successfully migrated `Key` record
105
+ ::AcidicJob::Key.where(id: ::AcidicJob::Run.select(:id)).delete_all
106
+
107
+ # return a report of the upgrade migration
108
+ {
109
+ run_records: ::AcidicJob::Run.count,
110
+ key_records: ::AcidicJob::Key.count,
111
+ errored_keys: errored_keys
112
+ }
113
+ end
114
+ end
115
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
- VERSION = "0.7.7"
4
+ VERSION = "1.0.0.pre3"
5
5
  end