acidic_job 0.7.5 → 1.0.0.beta.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +35 -12
  3. data/.gitignore +6 -0
  4. data/.rubocop.yml +21 -0
  5. data/.tool-versions +1 -0
  6. data/Gemfile +0 -25
  7. data/Gemfile.lock +181 -73
  8. data/README.md +274 -20
  9. data/UPGRADE_GUIDE.md +81 -0
  10. data/acidic_job.gemspec +17 -2
  11. data/bin/console +5 -2
  12. data/bin/sandbox +1958 -0
  13. data/combustion/log/test.log +0 -0
  14. data/gemfiles/rails_6.1.gemfile +8 -0
  15. data/gemfiles/rails_7.0.gemfile +8 -0
  16. data/lib/acidic_job/active_kiq.rb +114 -0
  17. data/lib/acidic_job/arguments.rb +22 -0
  18. data/lib/acidic_job/base.rb +11 -0
  19. data/lib/acidic_job/errors.rb +10 -17
  20. data/lib/acidic_job/{response.rb → finished_point.rb} +5 -7
  21. data/lib/acidic_job/idempotency_key.rb +27 -0
  22. data/lib/acidic_job/logger.rb +31 -0
  23. data/lib/acidic_job/mixin.rb +253 -0
  24. data/lib/acidic_job/perform_wrapper.rb +13 -26
  25. data/lib/acidic_job/processor.rb +96 -0
  26. data/lib/acidic_job/recovery_point.rb +4 -5
  27. data/lib/acidic_job/run.rb +299 -0
  28. data/lib/acidic_job/serializer.rb +24 -0
  29. data/lib/acidic_job/serializers/exception_serializer.rb +41 -0
  30. data/lib/acidic_job/serializers/finished_point_serializer.rb +24 -0
  31. data/lib/acidic_job/serializers/job_serializer.rb +35 -0
  32. data/lib/acidic_job/serializers/range_serializer.rb +28 -0
  33. data/lib/acidic_job/serializers/recovery_point_serializer.rb +25 -0
  34. data/lib/acidic_job/serializers/worker_serializer.rb +27 -0
  35. data/lib/acidic_job/version.rb +1 -1
  36. data/lib/acidic_job/workflow.rb +78 -0
  37. data/lib/acidic_job/workflow_builder.rb +35 -0
  38. data/lib/acidic_job/workflow_step.rb +103 -0
  39. data/lib/acidic_job.rb +29 -330
  40. data/lib/generators/acidic_job/drop_tables_generator.rb +26 -0
  41. data/lib/generators/acidic_job/install_generator.rb +27 -0
  42. data/lib/generators/acidic_job/templates/create_acidic_job_runs_migration.rb.erb +19 -0
  43. data/lib/generators/{templates/create_acidic_job_keys_migration.rb.erb → acidic_job/templates/drop_acidic_job_keys_migration.rb.erb} +10 -3
  44. metadata +235 -20
  45. data/.ruby_version +0 -1
  46. data/lib/acidic_job/deliver_transactionally_extension.rb +0 -26
  47. data/lib/acidic_job/key.rb +0 -33
  48. data/lib/acidic_job/no_op.rb +0 -11
  49. data/lib/acidic_job/perform_transactionally_extension.rb +0 -33
  50. data/lib/acidic_job/sidekiq_callbacks.rb +0 -45
  51. data/lib/acidic_job/staged.rb +0 -50
  52. data/lib/generators/acidic_job_generator.rb +0 -44
  53. data/lib/generators/templates/create_staged_acidic_jobs_migration.rb.erb +0 -10
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class WorkflowStep
5
+ def initialize(run:, job:)
6
+ @run = run
7
+ @job = job
8
+ end
9
+
10
+ def wrapped
11
+ # return a callable Proc with a consistent interface for the execution phase
12
+ proc do |_run|
13
+ current_step_result = process_current_step
14
+
15
+ if current_step_result.is_a?(FinishedPoint)
16
+ current_step_result
17
+ elsif next_item.present?
18
+ @run.attr_accessors[iterated_key] = prev_iterateds + [next_item]
19
+ @run.save!(validate: false)
20
+ RecoveryPoint.new(current_step_name)
21
+ elsif @run.next_step_finishes?
22
+ FinishedPoint.new
23
+ else
24
+ RecoveryPoint.new(@run.next_step_name)
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def process_current_step
32
+ result = nil
33
+
34
+ if iterable_key.present? && next_item.present? # have an item to iterate over, so pass it to the step method
35
+ result = current_callable.call(next_item)
36
+ elsif iterable_key.present? && next_item.nil? # have iterated over all items
37
+ result = true
38
+ elsif current_callable.arity.zero?
39
+ result = current_callable.call
40
+ else
41
+ raise TooManyParametersForStepMethod
42
+ end
43
+
44
+ result
45
+ end
46
+
47
+ def current_callable
48
+ return @job.method(current_step_name) if @job.respond_to?(current_step_name, _include_private = true)
49
+ # jobs can have no-op steps, especially so that they can use only the async/await mechanism for that step
50
+ return proc {} if @run.current_step_hash["awaits"].present?
51
+
52
+ raise UndefinedStepMethod
53
+ end
54
+
55
+ def iterable_key
56
+ # the `iterable_key` represents the name of the collection accessor
57
+ # that must be present in `@run.attr_accessors`; that is,
58
+ # it must have been passed to `persisting` when calling `with_acidic_workflow`
59
+ for_each = @run.current_step_hash["for_each"]
60
+
61
+ return unless for_each.present?
62
+
63
+ return for_each if @run.attr_accessors.key?(for_each)
64
+
65
+ raise UnknownForEachCollection
66
+ end
67
+
68
+ def iterated_key
69
+ # in order to ensure we don't iterate over successfully iterated values in previous runs,
70
+ # we need to store the collection of already processed values.
71
+ # we store this collection under a key bound to the current step to ensure multiple steps
72
+ # can iterate over the same collection.
73
+ "processed_#{current_step_name}_#{iterable_key}"
74
+ end
75
+
76
+ def prev_iterables
77
+ # The collection of values to iterate over
78
+ iterables = @run.attr_accessors.fetch(iterable_key, [])
79
+
80
+ return Array(iterables) if iterables.is_a?(Enumerable)
81
+
82
+ raise UniterableForEachCollection
83
+ end
84
+
85
+ def prev_iterateds
86
+ # The collection of values already iterated over
87
+ iterateds = @run.attr_accessors.fetch(iterated_key, [])
88
+
89
+ Array(iterateds)
90
+ end
91
+
92
+ def next_item
93
+ # The collection of values to iterate over now
94
+ curr_iterables = prev_iterables - prev_iterateds
95
+
96
+ curr_iterables.first
97
+ end
98
+
99
+ def current_step_name
100
+ @run.current_step_name
101
+ end
102
+ end
103
+ end
data/lib/acidic_job.rb CHANGED
@@ -2,338 +2,37 @@
2
2
 
3
3
  require_relative "acidic_job/version"
4
4
  require_relative "acidic_job/errors"
5
- require_relative "acidic_job/no_op"
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"
6
10
  require_relative "acidic_job/recovery_point"
7
- require_relative "acidic_job/response"
8
- require_relative "acidic_job/key"
9
- require_relative "acidic_job/staged"
11
+ require_relative "acidic_job/finished_point"
12
+ require_relative "acidic_job/run"
13
+ require_relative "acidic_job/workflow_step"
14
+ require_relative "acidic_job/workflow"
15
+ require_relative "acidic_job/processor"
10
16
  require_relative "acidic_job/perform_wrapper"
11
- require_relative "acidic_job/perform_transactionally_extension"
12
- require_relative "acidic_job/deliver_transactionally_extension"
13
- require_relative "acidic_job/sidekiq_callbacks"
14
- require "active_support/concern"
17
+ require_relative "acidic_job/mixin"
18
+ require_relative "acidic_job/base"
19
+ require_relative "acidic_job/active_kiq"
20
+
21
+ require_relative "acidic_job/serializers/exception_serializer"
22
+ require_relative "acidic_job/serializers/finished_point_serializer"
23
+ require_relative "acidic_job/serializers/job_serializer"
24
+ require_relative "acidic_job/serializers/range_serializer"
25
+ require_relative "acidic_job/serializers/recovery_point_serializer"
26
+ require_relative "acidic_job/serializers/worker_serializer"
27
+ require "active_job/serializers"
15
28
 
16
- # rubocop:disable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
17
29
  module AcidicJob
18
- extend ActiveSupport::Concern
19
-
20
- def self.wire_everything_up(klass)
21
- klass.attr_reader :key
22
- klass.attr_reader :staged_job_gid
23
- klass.attr_reader :arguments_for_perform
24
-
25
- # Extend ActiveJob with `perform_transactionally` class method
26
- klass.include PerformTransactionallyExtension
27
-
28
- ActionMailer::Parameterized::MessageDelivery.include DeliverTransactionallyExtension if defined?(ActionMailer)
29
-
30
- # Ensure our `perform` method always runs first to gather parameters
31
- klass.prepend PerformWrapper
32
-
33
- klass.prepend SidekiqCallbacks unless klass.respond_to?(:after_perform)
34
-
35
- klass.after_perform :delete_staged_job_record, if: :staged_job_gid
36
- end
37
-
38
- included do
39
- AcidicJob.wire_everything_up(self)
40
- end
41
-
42
- class_methods do
43
- def inherited(subclass)
44
- AcidicJob.wire_everything_up(subclass)
45
- super
46
- end
47
-
48
- def initiate(*args)
49
- operation = Sidekiq::Batch.new
50
- operation.on(:success, self, *args)
51
- operation.jobs do
52
- perform_async
53
- end
54
- end
55
- end
56
-
57
- # Number of seconds passed which we consider a held idempotency key lock to be
58
- # defunct and eligible to be locked again by a different job run. We try to
59
- # unlock keys on our various failure conditions, but software is buggy, and
60
- # this might not happen 100% of the time, so this is a hedge against it.
61
- IDEMPOTENCY_KEY_LOCK_TIMEOUT = 90
62
-
63
- # takes a block
64
- def with_acidity(given:)
65
- # execute the block to gather the info on what steps are defined for this job workflow
66
- steps = yield || []
67
-
68
- raise NoDefinedSteps if 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
- # find or create a Key record (our idempotency key) to store all information about this job
74
- #
75
- # A key concept here is that if two requests try to insert or update within
76
- # close proximity, one of the two will be aborted by Postgres because we're
77
- # using a transaction with SERIALIZABLE isolation level. It may not look
78
- # it, but this code is safe from races.
79
- key = ensure_idempotency_key_record(idempotency_key_value, workflow, given)
80
-
81
- # begin the workflow
82
- process_key(key)
83
- end
84
- alias_method :idempotently, :with_acidity
85
-
86
- def process_key(key)
87
- @key = key
88
-
89
- # if the key record is already marked as finished, immediately return its result
90
- return @key.succeeded? if @key.finished?
91
-
92
- # otherwise, we will enter a loop to process each step of the workflow
93
- @key.workflow.size.times do
94
- recovery_point = @key.recovery_point.to_s
95
- current_step = @key.workflow[recovery_point]
96
-
97
- if recovery_point == Key::RECOVERY_POINT_FINISHED.to_s # rubocop:disable Style/GuardClause
98
- break
99
- elsif current_step.nil?
100
- raise UnknownRecoveryPoint, "Defined workflow does not reference this step: #{recovery_point}"
101
- elsif (jobs = current_step.fetch("awaits", [])).any?
102
- acidic_step @key, current_step
103
- # THIS MUST BE DONE AFTER THE KEY RECOVERY POINT HAS BEEN UPDATED
104
- enqueue_step_parallel_jobs(jobs)
105
- # after processing the current step, break the processing loop
106
- # and stop this method from blocking in the primary worker
107
- # as it will continue once the background workers all succeed
108
- # so we want to keep the primary worker queue free to process new work
109
- # this CANNOT ever be `break` as that wouldn't exit the parent job,
110
- # only this step in the workflow, blocking as it awaits the next step
111
- return true
112
- else
113
- acidic_step @key, current_step
114
- end
115
- end
116
-
117
- # the loop will break once the job is finished, so simply report the status
118
- @key.succeeded?
119
- end
120
-
121
- def step(method_name, awaits: [])
122
- @_steps ||= []
123
-
124
- @_steps << {
125
- "does" => method_name.to_s,
126
- "awaits" => awaits
127
- }
128
-
129
- @_steps
130
- end
131
-
132
- def safely_finish_acidic_job
133
- # Short circuits execution by sending execution right to 'finished'.
134
- # So, ends the job "successfully"
135
- AcidicJob::Response.new
136
- end
137
-
138
- private
139
-
140
- def delete_staged_job_record
141
- return unless staged_job_gid
142
-
143
- staged_job = GlobalID::Locator.locate(staged_job_gid)
144
- staged_job.delete
145
- true
146
- rescue ActiveRecord::RecordNotFound
147
- true
148
- end
149
-
150
- def define_workflow(steps)
151
- steps << { "does" => Key::RECOVERY_POINT_FINISHED }
152
-
153
- {}.tap do |workflow|
154
- steps.each_cons(2).map do |enter_step, exit_step|
155
- enter_name = enter_step["does"]
156
- workflow[enter_name] = {
157
- "then" => exit_step["does"]
158
- }.merge(enter_step)
159
- end
160
- end
161
- end
162
-
163
- def ensure_idempotency_key_record(key_val, workflow, accessors)
164
- isolation_level = case ActiveRecord::Base.connection.adapter_name.downcase.to_sym
165
- when :sqlite
166
- :read_uncommitted
167
- else
168
- :serializable
169
- end
170
-
171
- ActiveRecord::Base.transaction(isolation: isolation_level) do
172
- key = Key.find_by(idempotency_key: key_val)
173
-
174
- if key.present?
175
- # Programs enqueuing multiple jobs with different parameters but the
176
- # same idempotency key is a bug.
177
- raise MismatchedIdempotencyKeyAndJobArguments if key.job_args != @arguments_for_perform
178
-
179
- # Only acquire a lock if the key is unlocked or its lock has expired
180
- # because the original job was long enough ago.
181
- raise LockedIdempotencyKey if key.locked_at && key.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
182
-
183
- # Lock the key and update latest run unless the job is already finished.
184
- key.update!(last_run_at: Time.current, locked_at: Time.current, workflow: workflow) unless key.finished?
185
- else
186
- key = Key.create!(
187
- idempotency_key: key_val,
188
- locked_at: Time.current,
189
- last_run_at: Time.current,
190
- recovery_point: workflow.first.first,
191
- job_name: self.class.name,
192
- job_args: @arguments_for_perform,
193
- workflow: workflow
194
- )
195
- end
196
-
197
- # set accessors for each argument passed in to ensure they are available
198
- # to the step methods the job will have written
199
- define_accessors_for_passed_arguments(accessors, key)
200
-
201
- # NOTE: we must return the `key` object from this transaction block
202
- # so that it can be returned from this method to the caller
203
- key
204
- end
205
- end
206
-
207
- def acidic_step(key, step)
208
- rescued_error = false
209
- step_callable = wrap_step_as_acidic_callable step
210
-
211
- begin
212
- key.with_lock do
213
- step_result = step_callable.call(key)
214
-
215
- step_result.call(key: key)
216
- end
217
- # QUESTION: Can an error not inherit from StandardError
218
- rescue StandardError => e
219
- rescued_error = e
220
- raise e
221
- ensure
222
- if rescued_error
223
- # If we're leaving under an error condition, try to unlock the idempotency
224
- # key right away so that another request can try again.3
225
- begin
226
- key.update_columns(locked_at: nil, error_object: rescued_error)
227
- rescue StandardError => e
228
- # We're already inside an error condition, so swallow any additional
229
- # errors from here and just send them to logs.
230
- puts "Failed to unlock key #{key.id} because of #{e}."
231
- end
232
- end
233
- end
234
- end
235
-
236
- def define_accessors_for_passed_arguments(passed_arguments, key)
237
- # first, get the current state of all accessors for both previously persisted and initialized values
238
- current_accessors = passed_arguments.stringify_keys.merge(key.attr_accessors)
239
-
240
- # next, ensure that `Key#attr_accessors` is populated with initial values
241
- key.update_column(:attr_accessors, current_accessors)
242
-
243
- current_accessors.each do |accessor, value|
244
- # the reader method may already be defined
245
- self.class.attr_reader accessor unless respond_to?(accessor)
246
- # but we should always update the value to match the current value
247
- instance_variable_set("@#{accessor}", value)
248
- # and we overwrite the setter to ensure any updates to an accessor update the `Key` stored value
249
- # Note: we must define the singleton method on the instance to avoid overwriting setters on other
250
- # instances of the same class
251
- define_singleton_method("#{accessor}=") do |current_value|
252
- instance_variable_set("@#{accessor}", current_value)
253
- key.attr_accessors[accessor] = current_value
254
- key.save!(validate: false)
255
- current_value
256
- end
257
- end
258
-
259
- true
260
- end
261
-
262
- # rubocop:disable Metrics/PerceivedComplexity
263
- def wrap_step_as_acidic_callable(step)
264
- # {:then=>:next_step, :does=>:enqueue_step, :awaits=>[WorkerWithEnqueueStep::FirstWorker]}
265
- current_step = step["does"]
266
- next_step = step["then"]
267
-
268
- callable = if respond_to? current_step, _include_private = true
269
- method(current_step)
270
- else
271
- proc {} # no-op
272
- end
273
-
274
- proc do |key|
275
- result = if callable.arity.zero?
276
- callable.call
277
- elsif callable.arity == 1
278
- callable.call(key)
279
- else
280
- raise TooManyParametersForStepMethod
281
- end
282
-
283
- if result.is_a?(Response)
284
- result
285
- elsif next_step.to_s == Key::RECOVERY_POINT_FINISHED
286
- Response.new
287
- else
288
- RecoveryPoint.new(next_step)
289
- end
290
- end
291
- end
292
- # rubocop:enable Metrics/PerceivedComplexity
293
-
294
- def enqueue_step_parallel_jobs(jobs)
295
- # TODO: GIVE PROPER ERROR
296
- # `batch` is available from Sidekiq::Pro
297
- raise SidekiqBatchRequired unless defined?(Sidekiq::Batch)
298
-
299
- batch.jobs do
300
- step_batch = Sidekiq::Batch.new
301
- # step_batch.description = "AcidicJob::Workflow Step: #{step}"
302
- step_batch.on(
303
- :success,
304
- "#{self.class.name}#step_done",
305
- # NOTE: options are marshalled through JSON so use only basic types.
306
- { "key_id" => @key.id }
307
- )
308
- # NOTE: The jobs method is atomic.
309
- # All jobs created in the block are actually pushed atomically at the end of the block.
310
- # If an error is raised, none of the jobs will go to Redis.
311
- step_batch.jobs do
312
- jobs.each do |worker_name|
313
- worker = worker_name.is_a?(String) ? worker_name.constantize : worker_name
314
- if worker.instance_method(:perform).arity.zero?
315
- worker.perform_async
316
- elsif worker.instance_method(:perform).arity == 1
317
- worker.perform_async(key.id)
318
- else
319
- raise TooManyParametersForParallelJob
320
- end
321
- end
322
- end
323
- end
324
- end
325
-
326
- def idempotency_key_value
327
- return job_id if defined?(job_id) && !job_id.nil?
328
- return jid if defined?(jid) && !jid.nil?
329
-
330
- Digest::SHA1.hexdigest [self.class.name, arguments_for_perform].flatten.join
331
- end
332
-
333
- def step_done(_status, options)
334
- key = Key.find(options["key_id"])
335
- # when a batch of jobs for a step succeeds, we begin the key processing again
336
- process_key(key)
337
- end
30
+ ::ActiveJob::Serializers.add_serializers(
31
+ Serializers::ExceptionSerializer,
32
+ Serializers::FinishedPointSerializer,
33
+ Serializers::JobSerializer,
34
+ Serializers::RangeSerializer,
35
+ Serializers::RecoveryPointSerializer,
36
+ Serializers::WorkerSerializer
37
+ )
338
38
  end
339
- # rubocop:enable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record"
4
+
5
+ module AcidicJob
6
+ module Generators
7
+ class DropTablesGenerator < ::Rails::Generators::Base
8
+ include ActiveRecord::Generators::Migration
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Drops the pre-1.0 tables for the AcidicJob::Key and AcidicJob::Staged models."
12
+
13
+ def copy_migration
14
+ migration_template "drop_acidic_job_keys_migration.rb.erb",
15
+ "db/migrate/drop_old_acidic_job_tables.rb",
16
+ migration_version: migration_version
17
+ end
18
+
19
+ protected
20
+
21
+ def migration_version
22
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record"
4
+
5
+ module AcidicJob
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ include ActiveRecord::Generators::Migration
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Generates a migration for the AcidicJob::Run table."
12
+
13
+ # Copies the migration template to db/migrate.
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",
17
+ migration_version: migration_version
18
+ end
19
+
20
+ protected
21
+
22
+ def migration_version
23
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :acidic_job_runs do |t|
4
+ t.boolean :staged, null: false, default: -> { false }
5
+ t.string :idempotency_key, null: false, index: { unique: true }
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.references :awaited_by, null: true, index: true
15
+ t.text :returning_to, null: true
16
+ t.timestamps
17
+ end
18
+ end
19
+ end
@@ -1,6 +1,6 @@
1
- class CreateAcidicJobKeys < <%= migration_class %>
1
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
- create_table :acidic_job_keys do |t|
3
+ drop_table :acidic_job_keys do |t|
4
4
  t.string :idempotency_key, null: false
5
5
  t.string :job_name, null: false
6
6
  t.text :job_args, null: true
@@ -11,10 +11,17 @@ class CreateAcidicJobKeys < <%= migration_class %>
11
11
  t.text :attr_accessors
12
12
  t.text :workflow
13
13
  t.timestamps
14
-
14
+
15
15
  t.index %i[idempotency_key job_name job_args],
16
16
  unique: true,
17
17
  name: "idx_acidic_job_keys_on_idempotency_key_n_job_name_n_job_args"
18
18
  end
19
+
20
+ drop_table :staged_acidic_jobs do |t|
21
+ t.string :adapter, null: false
22
+ t.string :job_name, null: false
23
+ t.text :job_args, null: true
24
+ t.timestamps
25
+ end
19
26
  end
20
27
  end