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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module AcidicJob
6
+ module Extensions
7
+ module ActiveJob
8
+ extend ActiveSupport::Concern
9
+
10
+ concerning :Serialization do
11
+ class_methods do
12
+ def serialize_with_arguments(*args, **kwargs)
13
+ job_or_instantiate(*args, **kwargs).serialize
14
+ end
15
+ end
16
+
17
+ def serialize_job(*_args, **_kwargs)
18
+ serialize
19
+ end
20
+ end
21
+
22
+ class_methods do
23
+ def perform_acidicly(*args, **kwargs)
24
+ raise UnsupportedExtension unless defined?(::ActiveJob) && self < ::ActiveJob::Base
25
+
26
+ serialized_job = serialize_with_arguments(*args, **kwargs)
27
+
28
+ AcidicJob::Run.create!(
29
+ staged: true,
30
+ job_class: name,
31
+ serialized_job: serialized_job,
32
+ idempotency_key: IdempotencyKey.value_for(serialized_job)
33
+ )
34
+ end
35
+ alias_method :perform_transactionally, :perform_acidicly
36
+ end
37
+ end
38
+ end
39
+ end
@@ -11,10 +11,10 @@ module AcidicJob
11
11
  end
12
12
  end
13
13
 
14
- # THIS IS A HACK THAT COPIES AND PASTES KEY PARTS OF THE `Noticed::Base` CODE
15
- # IN ORDER TO ALLOW US TO TRANSACTIONALLY DELIVER NOTIFICATIONS
16
- # THIS IS THUS LIABLE TO BREAK WHENEVER THAT GEM IS UPDATED
17
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
18
  delivery_methods = self.class.delivery_methods.dup
19
19
 
20
20
  Array.wrap(recipients).uniq.each do |recipient|
@@ -35,12 +35,18 @@ module AcidicJob
35
35
  recipient: recipient,
36
36
  record: record
37
37
  }
38
- job = job_class.new(args)
38
+ serialized_job = job_class.send(:job_or_instantiate, args).serialize
39
39
 
40
- AcidicJob::Run.stage!(job)
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
+ )
41
46
  end
42
47
  end
43
48
  end
49
+ alias deliver_transactionally deliver_acidicly
44
50
  end
45
51
  end
46
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
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "run"
4
-
5
3
  # Represents an action to set a new API response (which will be stored onto an
6
4
  # idempotency key). One possible option for a return from an #atomic_phase
7
5
  # block.
8
6
  module AcidicJob
9
7
  class FinishedPoint
10
8
  def call(run:)
11
- run.finish!
9
+ # Skip AR callbacks as there are none on the model
10
+ run.update_columns(
11
+ locked_at: nil,
12
+ recovery_point: Run::FINISHED_RECOVERY_POINT
13
+ )
12
14
  end
13
15
  end
14
16
  end
@@ -2,26 +2,23 @@
2
2
 
3
3
  module AcidicJob
4
4
  class IdempotencyKey
5
- def initialize(job)
6
- @job = job
7
- end
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
8
 
9
- def value(acidic_by: :job_id)
10
- case acidic_by
11
- when Proc
12
- proc_result = @job.instance_exec(&acidic_by)
13
- Digest::SHA1.hexdigest [@job.class.name, proc_result].flatten.join
14
- when :job_arguments
15
- Digest::SHA1.hexdigest [@job.class.name, @job.arguments].flatten.join
16
- else
17
- if @job.job_id.start_with? Run::STAGED_JOB_ID_PREFIX
18
- # "STG__#{idempotency_key}__#{encoded_global_id}"
19
- _prefix, idempotency_key, _encoded_global_id = @job.job_id.split("__")
20
- idempotency_key
21
- else
22
- @job.job_id
23
- end
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"]
24
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
25
22
  end
26
23
  end
27
24
  end
@@ -2,21 +2,48 @@
2
2
 
3
3
  module AcidicJob
4
4
  # NOTE: it is essential that this be a bare module and not an ActiveSupport::Concern
5
- # WHY?
6
5
  module PerformWrapper
7
- def perform(*args)
8
- @arguments = args
6
+ def perform(*args, **kwargs)
7
+ super_method = method(:perform).super_method
9
8
 
10
9
  # we don't want to run the `perform` callbacks twice, since ActiveJob already handles that for us
11
- if defined?(ActiveJob) && self.class < ActiveJob::Base
12
- super(*args)
13
- elsif defined?(Sidekiq) && self.class.include?(Sidekiq::Worker)
14
- run_callbacks :perform do
15
- super(*args)
16
- end
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)
17
14
  else
18
15
  raise UnknownJobAdapter
19
16
  end
20
17
  end
18
+
19
+ def sk_job?
20
+ defined?(Sidekiq) && self.class.include?(Sidekiq::Worker)
21
+ end
22
+
23
+ def aj_job?
24
+ defined?(ActiveJob) && self.class < ActiveJob::Base
25
+ end
26
+
27
+ private
28
+
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)
47
+ end
21
48
  end
22
49
  end
@@ -4,14 +4,15 @@
4
4
  # return from an #atomic_phase block.
5
5
  module AcidicJob
6
6
  class RecoveryPoint
7
- attr_reader :name
7
+ attr_accessor :name
8
8
 
9
9
  def initialize(name)
10
10
  @name = name
11
11
  end
12
12
 
13
13
  def call(run:)
14
- run.recover_to!(@name)
14
+ # Skip AR callbacks as there are none on the model
15
+ run.update_column(:recovery_point, @name)
15
16
  end
16
17
  end
17
18
  end
@@ -2,302 +2,76 @@
2
2
 
3
3
  require "active_record"
4
4
  require "global_id"
5
- require "base64"
6
5
  require "active_support/core_ext/object/with_options"
7
- require "active_support/core_ext/module/concerning"
8
- require "active_support/concern"
9
6
 
10
7
  module AcidicJob
11
8
  class Run < ActiveRecord::Base
12
9
  include GlobalID::Identification
13
10
 
14
11
  FINISHED_RECOVERY_POINT = "FINISHED"
15
- STAGED_JOB_ID_PREFIX = "STG"
16
- STAGED_JOB_ID_DELIMITER = "__"
17
- IDEMPOTENCY_KEY_LOCK_TIMEOUT_SECONDS = 2
18
12
 
19
13
  self.table_name = "acidic_job_runs"
20
14
 
21
- validates :idempotency_key, presence: true
22
- validate :not_awaited_but_unstaged
15
+ after_create_commit :enqueue_staged_job, if: :staged?
23
16
 
24
- def self.clear_finished
25
- # over-write any pre-existing relation queries on `recovery_point` and/or `error_object`
26
- to_purge = finished
17
+ serialize :error_object
18
+ serialize :serialized_job
19
+ serialize :workflow
20
+ store :attr_accessors
27
21
 
28
- count = to_purge.count
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
29
26
 
30
- return 0 if count.zero?
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
31
 
32
- AcidicJob.logger.info("Deleting #{count} finished AcidicJob runs")
33
- to_purge.delete_all
32
+ with_options unless: :staged? do
33
+ validates :last_run_at, presence: true
34
+ validates :recovery_point, presence: true
35
+ validates :workflow, presence: true
34
36
  end
35
37
 
36
- def succeeded?
37
- finished? && !errored?
38
+ def finished?
39
+ recovery_point == FINISHED_RECOVERY_POINT
38
40
  end
39
41
 
40
- concerning :Awaitable do
41
- included do
42
- belongs_to :awaited_by, class_name: "AcidicJob::Run", optional: true
43
- has_many :batched_runs, class_name: "AcidicJob::Run", foreign_key: "awaited_by_id"
44
-
45
- scope :awaited, -> { where.not(awaited_by: nil) }
46
- scope :unawaited, -> { where(awaited_by: nil) }
47
-
48
- after_update_commit :proceed_with_parent, if: :finished?
49
-
50
- serialize :returning_to, AcidicJob::Serializer
51
- end
52
-
53
- class_methods do
54
- def await!(job, by:, return_to:)
55
- create!(
56
- staged: true,
57
- awaited_by: by,
58
- job_class: job.class.name,
59
- serialized_job: job.serialize,
60
- idempotency_key: job.idempotency_key
61
- )
62
- by.update(returning_to: return_to)
63
- end
64
- end
65
-
66
- def awaited?
67
- awaited_by.present?
68
- end
69
-
70
- private
71
-
72
- def proceed_with_parent
73
- return unless finished?
74
- return unless awaited_by.present?
75
- return if awaited_by.batched_runs.outstanding.any?
76
-
77
- AcidicJob.logger.log_run_event("Proceeding with parent job...", job, self)
78
- awaited_by.unlock!
79
- awaited_by.proceed
80
- AcidicJob.logger.log_run_event("Proceeded with parent job.", job, self)
81
- end
82
-
83
- protected
84
-
85
- def proceed
86
- # this needs to be explicitly set so that `was_workflow_job?` appropriately returns `true`
87
- # TODO: replace this with some way to check the type of the job directly
88
- # either via class method or explicit module inclusion
89
- job.instance_variable_set(:@acidic_job_run, self)
90
-
91
- workflow = Workflow.new(self, job, returning_to)
92
- # TODO: WRITE REGRESSION TESTS FOR PARALLEL JOB FAILING AND RETRYING THE ORIGINAL STEP
93
- workflow.progress_to_next_step
94
-
95
- # when a batch of jobs for a step succeeds, we begin processing the `AcidicJob::Run` record again
96
- return if finished?
97
-
98
- AcidicJob.logger.log_run_event("Re-enqueuing parent job...", job, self)
99
- enqueue_job
100
- AcidicJob.logger.log_run_event("Re-enqueued parent job.", job, self)
101
- end
102
- end
103
-
104
- concerning :Stageable do
105
- included do
106
- after_create_commit :enqueue_job, if: :staged?
107
-
108
- validates :staged, inclusion: { in: [true, false] } # uses database default
109
-
110
- scope :staged, -> { where(staged: true) }
111
- scope :unstaged, -> { where(staged: false) }
112
- end
113
-
114
- class_methods do
115
- def stage!(job)
116
- create!(
117
- staged: true,
118
- job_class: job.class.name,
119
- serialized_job: job.serialize,
120
- idempotency_key: job.try(:idempotency_key) || job.job_id
121
- )
122
- end
123
- end
124
-
125
- private
126
-
127
- def job_id
128
- return idempotency_key unless staged?
129
-
130
- # encode the identifier for this record in the job ID
131
- global_id = to_global_id.to_s.remove("gid://")
132
- # base64 encoding for minimal security
133
- encoded_global_id = Base64.urlsafe_encode64(global_id, padding: false)
134
-
135
- [
136
- STAGED_JOB_ID_PREFIX,
137
- idempotency_key,
138
- encoded_global_id
139
- ].join(STAGED_JOB_ID_DELIMITER)
140
- end
141
- end
142
-
143
- concerning :Workflowable do
144
- included do
145
- serialize :workflow, AcidicJob::Serializer
146
- serialize :error_object, AcidicJob::Serializer
147
- store :attr_accessors, coder: AcidicJob::Serializer
148
-
149
- with_options unless: :staged? do
150
- validates :last_run_at, presence: true
151
- validates :recovery_point, presence: true
152
- validates :workflow, presence: true
153
- end
154
- end
155
-
156
- def workflow?
157
- self[:workflow].present?
158
- end
159
-
160
- def attr_accessors
161
- self[:attr_accessors] || {}
162
- end
163
-
164
- def current_step_name
165
- recovery_point
166
- end
167
-
168
- def current_step_hash
169
- workflow[current_step_name]
170
- end
171
-
172
- def next_step_name
173
- current_step_hash.fetch("then")
174
- end
175
-
176
- def current_step_awaits
177
- current_step_hash["awaits"]
178
- end
179
-
180
- def next_step_finishes?
181
- next_step_name.to_s == FINISHED_RECOVERY_POINT
182
- end
183
-
184
- def current_step_finished?
185
- current_step_name.to_s == FINISHED_RECOVERY_POINT
186
- end
187
- end
188
-
189
- concerning :Jobbable do
190
- included do
191
- serialize :serialized_job, JSON
192
-
193
- validates :serialized_job, presence: true
194
- validates :job_class, presence: true
195
- end
196
-
197
- def job
198
- return @job if defined? @job
199
-
200
- serialized_job_for_run = serialized_job.merge("job_id" => job_id)
201
- job_class_for_run = job_class.constantize
202
-
203
- @job = job_class_for_run.deserialize(serialized_job_for_run)
204
- end
205
-
206
- def enqueue_job
207
- job.enqueue
208
-
209
- # NOTE: record will be deleted after the job has successfully been performed
210
- true
211
- end
212
- end
213
-
214
- concerning :Finishable do
215
- included do
216
- scope :finished, -> { where(recovery_point: FINISHED_RECOVERY_POINT) }
217
- scope :outstanding, lambda {
218
- where.not(recovery_point: FINISHED_RECOVERY_POINT).or(where(recovery_point: [nil, ""]))
219
- }
220
- end
221
-
222
- def finish!
223
- finish and unlock and save!
224
- end
225
-
226
- def finish
227
- self.recovery_point = FINISHED_RECOVERY_POINT
228
- self
229
- end
230
-
231
- def finished?
232
- recovery_point.to_s == FINISHED_RECOVERY_POINT
233
- end
42
+ def succeeded?
43
+ finished? && !failed?
234
44
  end
235
45
 
236
- concerning :Unlockable do
237
- included do
238
- scope :unlocked, -> { where(locked_at: nil) }
239
- scope :locked, -> { where.not(locked_at: nil) }
240
- end
241
-
242
- def unlock!
243
- unlock and save!
244
- end
245
-
246
- def unlock
247
- self.locked_at = nil
248
- self
249
- end
250
-
251
- def locked?
252
- locked_at.present?
253
- end
254
-
255
- def lock_active?
256
- return false if locked_at.nil?
257
-
258
- locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT_SECONDS
259
- end
46
+ def failed?
47
+ error_object.present?
260
48
  end
261
49
 
262
- concerning :ErrorStoreable do
263
- included do
264
- scope :unerrored, -> { where(error_object: nil) }
265
- scope :errored, -> { where.not(error_object: nil) }
266
- end
50
+ private
267
51
 
268
- def store_error!(error)
269
- reload and unlock and store_error(error) and save!
270
- end
52
+ def enqueue_staged_job
53
+ return unless staged?
271
54
 
272
- def store_error(error)
273
- self.error_object = error
274
- self
275
- end
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}"
276
60
 
277
- def errored?
278
- error_object.present?
279
- end
280
- end
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
281
68
 
282
- concerning :Recoverable do
283
- def recover_to!(point)
284
- recover_to(point) and save!
285
- end
286
-
287
- def recover_to(point)
288
- self.recovery_point = point
289
- self
290
- end
291
-
292
- def known_recovery_point?
293
- workflow.key?(recovery_point)
294
- end
295
- end
69
+ job = job_class.constantize.deserialize(serialized_staged_job)
296
70
 
297
- def not_awaited_but_unstaged
298
- return true unless awaited? && !staged?
71
+ job.enqueue
299
72
 
300
- errors.add(:base, "cannot be awaited by another job but not staged")
73
+ # NOTE: record will be deleted after the job has successfully been performed
74
+ true
301
75
  end
302
76
  end
303
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