ajdc 0.0.1 → 0.1.0

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +380 -13
  4. data/db/durable_schema.rb +57 -0
  5. data/lib/active_job/continuable/callbacks.rb +48 -0
  6. data/lib/active_job/continuable/configurable.rb +75 -0
  7. data/lib/active_job/continuation/callbacks.rb +18 -0
  8. data/lib/active_job/continuation/rescue_handlers_first.rb +29 -0
  9. data/lib/active_job/durable/args_mapper.rb +41 -0
  10. data/lib/active_job/durable/config.rb +100 -0
  11. data/lib/active_job/durable/continuation.rb +24 -0
  12. data/lib/active_job/durable/execution.rb +438 -0
  13. data/lib/active_job/durable/housekeeping_job.rb +16 -0
  14. data/lib/active_job/durable/record.rb +17 -0
  15. data/lib/active_job/durable/run.rb +165 -0
  16. data/lib/active_job/durable/step.rb +14 -0
  17. data/lib/active_job/durable/wake_job.rb +16 -0
  18. data/lib/active_job/durable.rb +251 -0
  19. data/lib/ajdc/railtie.rb +18 -0
  20. data/lib/ajdc/version.rb +1 -1
  21. data/lib/ajdc.rb +1 -0
  22. data/lib/generators/ajdc/install/USAGE +19 -0
  23. data/lib/generators/ajdc/install/install_generator.rb +66 -0
  24. data/lib/generators/ajdc/install/templates/create_active_job_durable_tables.rb.tt +56 -0
  25. data/lib/generators/ajdc/install/templates/initializer.rb.tt +4 -0
  26. data/skills/ajdc/SKILL.md +117 -0
  27. data/skills/ajdc/examples/README.md +11 -0
  28. data/skills/ajdc/examples/agent-loop.md +57 -0
  29. data/skills/ajdc/examples/halting.md +84 -0
  30. data/skills/ajdc/examples/pipeline.md +73 -0
  31. data/skills/ajdc/examples/signals.md +139 -0
  32. data/skills/ajdc/examples/timers.md +136 -0
  33. data/skills/ajdc/examples/uniqueness.md +87 -0
  34. data/skills/ajdc/references/api.md +157 -0
  35. data/skills/ajdc/references/decision-guide.md +130 -0
  36. data/skills/ajdc/references/installation.md +98 -0
  37. data/skills/ajdc/references/testing.md +152 -0
  38. data/skills/ajdc/references/transactions.md +68 -0
  39. metadata +83 -7
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Durable
5
+ # Maps a method's arguments to its parameters
6
+ class ArgsMapper # :nodoc:
7
+ Keyword = Struct.new(:name, :value)
8
+
9
+ def initialize(owner, method_name)
10
+ @owner = owner
11
+ @method_name = method_name
12
+ end
13
+
14
+ def check!(names)
15
+ unknown = names - known
16
+ raise ArgumentError, "unknown #{method_name} identifier parameters #{unknown.inspect} (#{method_name} has #{known.join(", ")})" if unknown.any?
17
+ end
18
+
19
+ def map(schema, *args, **kwargs)
20
+ if schema.is_a?(Proc)
21
+ value = schema.call(*args, **kwargs)
22
+ value.is_a?(Array) ? value : [value]
23
+ elsif schema.present?
24
+ schema.map { |name| (index = positional_names.index(name)) ? args[index] : kwargs[name] }
25
+ else
26
+ args + kwargs.sort_by { |name, _| name.to_s }.map { |name, value| Keyword.new(name, value) }
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :owner, :method_name
33
+
34
+ def parameters = @parameters ||= owner.instance_method(method_name).parameters
35
+
36
+ def positional_names = @positional_names ||= parameters.filter_map { |type, name| name if type == :req || type == :opt }
37
+
38
+ def known = @known ||= parameters.filter_map { |type, name| name if %i[req opt key keyreq].include?(type) }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Durable
5
+ # A durable job class's configuration: what identifies its runs, what keeps
6
+ # them unique, which errors halt them, how to generate workflow keys.
7
+ class Config # :nodoc:
8
+ ON_CONFLICT = %i[skip reject replace].freeze
9
+
10
+ attr_reader :identity, :uniqueness, :on_conflict, :halt_errors
11
+
12
+ def initialize(job_class)
13
+ @job_class = job_class
14
+ @identity = nil
15
+ @uniqueness = nil
16
+ @on_conflict = :skip
17
+ @halt_errors = []
18
+ end
19
+
20
+ def initialize_copy(other)
21
+ super
22
+ @identity = @identity.dup
23
+ @uniqueness = @uniqueness.dup
24
+ @halt_errors = @halt_errors.dup
25
+ @args_mapper = nil
26
+ end
27
+
28
+ # A copy for a subclass.
29
+ def inherit(job_class) = dup.tap { |copy| copy.job_class = job_class }
30
+
31
+ def identified_by(*names, &block)
32
+ @identity = block || names
33
+ @args_mapper = nil
34
+ end
35
+
36
+ def unique_by(*names, on_conflict: :skip, &block)
37
+ unless ON_CONFLICT.include?(on_conflict)
38
+ raise ArgumentError, "unique_by: on_conflict must be one of #{ON_CONFLICT.map(&:inspect).join(", ")}, got #{on_conflict.inspect}"
39
+ end
40
+
41
+ @uniqueness = block || names
42
+ @on_conflict = on_conflict
43
+ @args_mapper = nil
44
+ end
45
+
46
+ def halt_on(*errors) = @halt_errors += errors
47
+
48
+ def halt_error?(error) = halt_errors.any? { |klass| error.is_a?(klass) }
49
+
50
+ def unique? = !uniqueness.nil?
51
+
52
+ # The run's identity inside the class: the `identified_by` components (else
53
+ # the `unique_by` ones, else every argument) joined with ":".
54
+ def workflow_key(...) = build_key((identity || !uniqueness) ? identity : uniqueness, ...)
55
+
56
+ # The identity one run holds at a time: the `unique_by` components.
57
+ def active_key(...) = build_key(uniqueness, ...)
58
+
59
+ # A key for arguments that are not deserialized yet.
60
+ def self.digest_key(serialized_arguments) = Digest::SHA256.hexdigest(ActiveSupport::JSON.encode(serialized_arguments))
61
+
62
+ protected
63
+
64
+ attr_writer :job_class
65
+
66
+ private
67
+
68
+ attr_reader :job_class
69
+
70
+ # The named components are checked against `perform` when a key is first
71
+ # built; an unknown name raises on every key until it is fixed.
72
+ def args_mapper
73
+ @args_mapper ||= ArgsMapper.new(job_class, :perform).tap do |mapper|
74
+ mapper.check!([identity, uniqueness].select { |schema| schema.is_a?(Array) }.flatten)
75
+ end
76
+ end
77
+
78
+ def build_key(schema, *args, **kwargs)
79
+ components = args_mapper.map(schema, *args, **kwargs)
80
+ if components.empty?
81
+ self.class.digest_key(Arguments.serialize(kwargs.empty? ? args : [*args, Hash.ruby2_keywords_hash(kwargs)]))
82
+ else
83
+ components.map { |component| build_key_component(component) }.join(":")
84
+ end
85
+ end
86
+
87
+ def build_key_component(value)
88
+ case value
89
+ when ArgsMapper::Keyword then "#{value.name}=#{build_key_component(value.value)}"
90
+ when GlobalID::Identification
91
+ collection = value.respond_to?(:model_name) ? value.model_name.collection : ActiveModel::Name.new(value.class).collection
92
+ "#{collection}/#{value.id}"
93
+ when Symbol, String, Integer, Float, true, false then value.to_s
94
+ when nil then ""
95
+ else Digest::SHA256.hexdigest(ActiveSupport::JSON.encode(Arguments.serialize([value])))[0, 16]
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Durable
5
+ # A continuation whose steps take a timer (`wait:` or `wait_until:`) and can
6
+ # await a signal; the running step's options are `step_options`.
7
+ class Continuation < ActiveJob::Continuation
8
+ prepend ActiveJob::Continuation::Callbacks
9
+
10
+ # The options of the running step (`isolated:`, `wait:`, `wait_until:`,
11
+ # `await:`), `nil` between steps.
12
+ attr_reader :step_options
13
+
14
+ private
15
+
16
+ def run_step(name, start:, isolated:, wait: nil, wait_until: nil, await: false, &block)
17
+ @step_options = {isolated:, wait:, wait_until:, await:}
18
+ super(name, start:, isolated:, &block)
19
+ ensure
20
+ @step_options = nil
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Durable
5
+ # Mediates between a durable job and its run: reads the job's configuration
6
+ # and state, and keeps the run and step rows in sync with its execution.
7
+ # One per job instance.
8
+ class Execution # :nodoc:
9
+ attr_accessor :workflow_key
10
+
11
+ # The value an `await` step's handler receives.
12
+ attr_reader :await_value
13
+
14
+ def initialize(job)
15
+ @job = job
16
+ end
17
+
18
+ def run_id = @run&.id
19
+
20
+ # The run must be the only source of truth, so drop the base continuation
21
+ # parameters from the payload.
22
+ def serialize(payload)
23
+ payload = payload.except("continuation", "attributes", "resumptions").merge("durable_run_id" => run_id)
24
+ # A workflow key makes sense only when the row exists
25
+ payload["durable_workflow_key"] = workflow_key if workflow_key && !@run
26
+ payload
27
+ end
28
+
29
+ def deserialize(job_data)
30
+ @serialized_run_id = job_data["durable_run_id"]
31
+ self.workflow_key = job_data["durable_workflow_key"]
32
+ end
33
+
34
+ # Rebuilds the continuation, the resumption count and the attribute values
35
+ # from the run the payload references.
36
+ def restore!
37
+ run_id, @serialized_run_id = @serialized_run_id, nil
38
+ return unless run_id
39
+
40
+ run = Run.find_by(id: run_id, active_job_id: job.job_id)
41
+ raise RunNotFoundError, "Run #{run_id} for #{job.class.name} (Job ID: #{job.job_id}) was not found" unless run
42
+
43
+ @run = run
44
+ @state_written = run.serialized_state
45
+ @deadline, @deadline_step = run.wake_at, run.current_step
46
+ job.resumptions = run.resumptions
47
+ job.continuation = job.continuation_class.new(job, serialized_progress(run))
48
+ # A run that starts from its parked job was re-enqueued by `resume!` (or by
49
+ # the backend), not by Continuation, so it is not one of its resumptions;
50
+ # `continue` still adds one when the run has progress, so we need to adjust
51
+ job.resumptions -= 1 if run.parked_job.present? && job.continuation.started?
52
+ if run.state.present? && job.respond_to?(:deserialize_attribute_values, true)
53
+ job.send(:deserialize_attribute_values, run.serialized_state)
54
+ end
55
+ end
56
+
57
+ def enqueued!
58
+ attributes = {status: "enqueued", finished_at: nil, transitioned_at: Time.current}
59
+ attributes[:state] = state unless @serialized_run_id # a job built from its payload has not read the row yet
60
+ upsert_run!(if_status: Run::CANCELLABLE_STATUSES, **attributes)
61
+ !@conflict
62
+ end
63
+
64
+ # Wraps `perform_now`: a run is `discarded` when Active Job swallowed the
65
+ # error and `failed` when the error is raised to the backend; a retried job
66
+ # is `enqueued` again by `enqueue`.
67
+ def perform_now
68
+ @discarded_error = nil
69
+ result = yield
70
+ discarded!(@discarded_error) if @discarded_error
71
+ result
72
+ rescue Exception => error # rubocop:disable Lint/RescueException
73
+ failed!(error) unless @conflict
74
+ raise
75
+ end
76
+
77
+ # Remembers the error Active Job discarded the job with; `perform_now`
78
+ # writes the run as `discarded` once it returns.
79
+ def record_discard(error)
80
+ @discarded_error = error
81
+ end
82
+
83
+ # Wraps Continuable's `continue`.
84
+ def perform
85
+ return if run&.terminal? # cancelled while the job was in the queue
86
+
87
+ @resumed = false
88
+ @parked = false
89
+ started! or return # false means skip due to the uniqueness constraints
90
+ yield
91
+ completed! unless @resumed
92
+ rescue Cancelled
93
+ # `Run#cancel!` ended the run; nothing happens after
94
+ end
95
+
96
+ # The step's durability wrapper
97
+ def step
98
+ step, options = job.current_step, job.continuation.step_options
99
+ wait!(step, **options.slice(:wait, :wait_until, :await)) if options.values_at(:wait, :wait_until, :await).any?
100
+ step_started!(step, **options.slice(:isolated, :await))
101
+ begin
102
+ yield
103
+ step_completed!(step)
104
+ rescue Exception => error # rubocop:disable Lint/RescueException
105
+ step_stopped!(step, error)
106
+ raise
107
+ end
108
+ ensure
109
+ @step = nil
110
+ end
111
+
112
+ # The cursor's durabilty
113
+ def checkpoint!
114
+ return unless run
115
+
116
+ if @step && (current = job.continuation.instrumentation[:current_step])
117
+ @step.update_columns(cursor: serialize_cursor(step_cursor(current)))
118
+ end
119
+
120
+ attributes = {last_heartbeat_at: Time.current}
121
+ state = self.state
122
+ attributes[:state] = state unless state == @state_written
123
+ write_running!(**attributes)
124
+ end
125
+
126
+ def resume!(error)
127
+ raise error if halt?(error)
128
+
129
+ @resumed = true
130
+ return false if @parked
131
+
132
+ write_running!(state:, resumptions: job.resumptions, last_heartbeat_at: Time.current)
133
+ true
134
+ end
135
+
136
+ def halted!(error)
137
+ halt = error.is_a?(Halt)
138
+ write_run(
139
+ if_status: "running",
140
+ status: "halted",
141
+ state:,
142
+ resumptions: job.resumptions,
143
+ parked_job: job.serialize,
144
+ halt_reason: (error.reason&.to_s if halt),
145
+ error_class: (error.class.name unless halt),
146
+ error_message: (error.message unless halt),
147
+ transitioned_at: Time.current
148
+ )
149
+ end
150
+
151
+ private
152
+
153
+ attr_reader :job
154
+
155
+ def config = job.class.durable_config
156
+
157
+ def run = @run ||= Run.find_by(active_job_id: job.job_id)
158
+
159
+ def halt?(error) = error.is_a?(Halt) || config.halt_error?(error)
160
+
161
+ def serialized_progress(run)
162
+ progress = {"completed" => Array(run.completed_steps)}
163
+ if run.current_step && (cursor, _ = Step.where(run_id: run.id, name: run.current_step).order(attempt: :desc).pick(:cursor, :attempt))
164
+ progress["current"] = [run.current_step, cursor_for_continuation(cursor)]
165
+ end
166
+ progress
167
+ end
168
+
169
+ def cursor_for_continuation(serialized_cursor)
170
+ if ActiveJob::Continuation.private_method_defined?(:serialized_current)
171
+ serialized_cursor # Rails 8.2+
172
+ else
173
+ Arguments.deserialize([serialized_cursor]).first # Rails <8.2
174
+ end
175
+ end
176
+
177
+ # Last write wins, within `if_status:`. False, and nothing written, when the
178
+ # row was not created because another run holds the key (`on_conflict: :skip`).
179
+ def upsert_run!(if_status: nil, **attributes)
180
+ return write_run(if_status:, **attributes) if run
181
+
182
+ attributes = {
183
+ job_class: job.class.name,
184
+ key:,
185
+ active_key: (active_key if config.unique?),
186
+ arguments: job.send(:serialize_arguments_if_needed, job.arguments),
187
+ state:,
188
+ transitioned_at: Time.current
189
+ }.merge(attributes)
190
+ run = create_run(attributes) or return false
191
+ @run = run
192
+
193
+ if run.previously_new_record?
194
+ @state_written = attributes[:state]
195
+ true
196
+ else
197
+ write_run(if_status:, **attributes)
198
+ end
199
+ end
200
+
201
+ # Inserts the row. With `unique_by`, a run holding the same
202
+ # `active_key` decides first, by `on_conflict`; the unique index settles a
203
+ # race between two first enqueues, and the loser looks again, once.
204
+ def create_run(attributes, retried: false)
205
+ Run.transaction(requires_new: true) do
206
+ if config.unique? && (live = Run.find_by(job_class: job.class.name, active_key: attributes[:active_key]))
207
+ resolve_conflict!(live) or next
208
+ end
209
+ Run.create!(active_job_id: job.job_id, **attributes)
210
+ end
211
+ rescue ActiveRecord::RecordNotUnique
212
+ Run.find_by(active_job_id: job.job_id) || (retried ? raise : create_run(attributes, retried: true))
213
+ end
214
+
215
+ # True when this run may be inserted.
216
+ def resolve_conflict!(live)
217
+ case config.on_conflict
218
+ when :skip
219
+ @conflict = live
220
+ false
221
+ when :reject
222
+ @conflict = live
223
+ raise RunAlreadyExists.new(live)
224
+ when :replace
225
+ begin
226
+ live.cancel!
227
+ rescue NotCancellable
228
+ # ended meanwhile: its key is free
229
+ end
230
+ true
231
+ end
232
+ end
233
+
234
+ def started!
235
+ now = Time.current
236
+ written = upsert_run!(
237
+ if_status: Run::CANCELLABLE_STATUSES,
238
+ status: "running",
239
+ started_at: run&.started_at || now,
240
+ last_heartbeat_at: now,
241
+ resumptions: job.continuation.started? ? job.resumptions + 1 : job.resumptions,
242
+ parked_job: nil,
243
+ transitioned_at: now
244
+ )
245
+ return false if @conflict
246
+
247
+ written or raise Cancelled, "Run #{run.id} was cancelled"
248
+ end
249
+
250
+ def completed!
251
+ now = Time.current
252
+ write_running!(
253
+ status: "completed",
254
+ current_step: nil,
255
+ active_key: nil,
256
+ state:,
257
+ resumptions: job.resumptions,
258
+ finished_at: now,
259
+ transitioned_at: now
260
+ )
261
+ end
262
+
263
+ def failed!(error)
264
+ now = Time.current
265
+ upsert_run!(
266
+ if_status: Run::CANCELLABLE_STATUSES,
267
+ status: "failed",
268
+ state:,
269
+ resumptions: job.resumptions,
270
+ parked_job: job.serialize,
271
+ error_class: error.class.name,
272
+ error_message: error.message,
273
+ finished_at: now,
274
+ transitioned_at: now
275
+ )
276
+ end
277
+
278
+ def discarded!(error)
279
+ now = Time.current
280
+ upsert_run!(
281
+ if_status: Run::CANCELLABLE_STATUSES,
282
+ status: "discarded",
283
+ active_key: nil,
284
+ error_class: error.class.name,
285
+ error_message: error.message,
286
+ finished_at: now,
287
+ transitioned_at: now
288
+ )
289
+ end
290
+
291
+ def write_run(if_status: nil, **attributes)
292
+ run = self.run or return true
293
+
294
+ @state_written = attributes[:state] if attributes.key?(:state)
295
+ scope = Run.where(id: run.id)
296
+ scope = scope.where(status: if_status) if if_status
297
+ scope.update_all(**attributes, updated_at: Time.current).positive?
298
+ end
299
+
300
+ def write_running!(**attributes)
301
+ write_run(if_status: "running", **attributes) or raise Cancelled, "Run #{run.id} was cancelled"
302
+ end
303
+
304
+ def step_started!(step, isolated:, await:)
305
+ run = self.run or return
306
+ name = step.name.to_s
307
+
308
+ @step_await = await
309
+ Run.transaction do
310
+ signals = pending_signals(lock: true)
311
+ attributes = {current_step: name, wake_at: nil}
312
+ if signals.key?(name)
313
+ attributes[:pending_signals] = signals.except(name)
314
+ @await_value = signals[name] if @step_await
315
+ end
316
+ write_running!(**attributes)
317
+
318
+ @step = run.steps.create(
319
+ name:,
320
+ position: job.continuation.instrumentation[:completed_steps].size + 1,
321
+ attempt: Step.where(run_id: run.id, name:).maximum(:attempt).to_i + 1,
322
+ status: "started",
323
+ cursor: serialize_cursor(step_cursor(step)),
324
+ isolated:,
325
+ started_at: Time.current
326
+ )
327
+ end
328
+ @deadline = nil
329
+ end
330
+
331
+ def step_completed!(step)
332
+ step_row = @step or return
333
+ now = Time.current
334
+ completed_steps = job.continuation.instrumentation[:completed_steps].map(&:to_s) << step.name.to_s
335
+ write_running!(completed_steps:, current_step: nil, state:, last_heartbeat_at: now)
336
+
337
+ @step = nil
338
+ step_row.update_columns(status: "completed", cursor: serialize_cursor(step_cursor(step)), finished_at: now)
339
+ end
340
+
341
+ def step_stopped!(step, error)
342
+ step_row = @step or return
343
+ @step = nil
344
+ status, error =
345
+ case error
346
+ when ActiveJob::Continuation::Interrupt then "interrupted"
347
+ when Cancelled then "cancelled"
348
+ when Halt then "halted"
349
+ else halt?(error) ? ["halted", error] : ["failed", error]
350
+ end
351
+
352
+ step_row.update_columns(
353
+ status:,
354
+ cursor: serialize_cursor(step_cursor(step)),
355
+ error_class: error&.class&.name,
356
+ error_message: error&.message,
357
+ finished_at: Time.current
358
+ )
359
+ end
360
+
361
+ # Parks the run when the step's signal has not arrived and its deadline has
362
+ # not passed.
363
+ def wait!(step, wait:, wait_until:, await:)
364
+ run or return
365
+ name = step.name.to_s
366
+ resumed = step.resumed? && !(await && halted?(name))
367
+ @await_value = (step.cursor if resumed) if await
368
+ return if resumed
369
+
370
+ status = Run.transaction do
371
+ next if pending_signals(lock: true).key?(name)
372
+
373
+ deadline = deadline(name, wait, wait_until)
374
+ next if deadline && deadline <= Time.current
375
+
376
+ parked = await ? "awaiting" : "waiting"
377
+ write_running!(
378
+ status: parked, current_step: name, wake_at: deadline, state:, resumptions: job.resumptions,
379
+ parked_job: job.serialize, transitioned_at: Time.current
380
+ )
381
+ @deadline, @deadline_step = deadline, name
382
+ parked
383
+ end
384
+ return unless status
385
+
386
+ @parked = true
387
+ job.interrupt!(reason: status.to_sym)
388
+ end
389
+
390
+ # A `wait_until:` is read again at every wake; a `wait:` counts from the
391
+ # first time the line is reached and is then kept on the row.
392
+ def deadline(name, wait, wait_until)
393
+ if wait_until
394
+ timer_value(wait_until)
395
+ elsif wait
396
+ (@deadline if @deadline_step == name) || Time.current + timer_value(wait)
397
+ end
398
+ end
399
+
400
+ def timer_value(value) = value.respond_to?(:call) ? value.call : value
401
+
402
+ def halted?(name) = Step.where(run_id: run.id, name:).order(attempt: :desc).pick(:status) == "halted"
403
+
404
+ def pending_signals(lock: false)
405
+ scope = Run.where(id: run.id)
406
+ scope = scope.lock if lock
407
+ scope.pick(:pending_signals) || {}
408
+ end
409
+
410
+ # An `await` step's cursor is the signal value, whatever the resumed step carries.
411
+ def step_cursor(step) = @step_await ? @await_value : step.cursor
412
+
413
+ def serialize_cursor(cursor) = Arguments.serialize([cursor]).first
414
+
415
+ # The attribute values as `ActiveJob::Attributes#serialize` puts them under
416
+ # `"attributes"`. Rails 8.1 has no Attributes.
417
+ def state = job.respond_to?(:serialize_attribute_values, true) ? job.send(:serialize_attribute_values) : {}
418
+
419
+ # The run's identity inside the class: either `set(workflow_key:)` or
420
+ # the configured one.
421
+ def key
422
+ return workflow_key if workflow_key
423
+ return Config.digest_key(job.send(:serialize_arguments_if_needed, job.arguments)) if job.send(:arguments_serialized?)
424
+
425
+ config.workflow_key(*job.arguments)
426
+ end
427
+
428
+ # The identity one run holds at a time: either `set(workflow_key:)` or else
429
+ # the configured one.
430
+ def active_key
431
+ return workflow_key if workflow_key
432
+ return Config.digest_key(job.send(:serialize_arguments_if_needed, job.arguments)) if job.send(:arguments_serialized?)
433
+
434
+ config.active_key(*job.arguments)
435
+ end
436
+ end
437
+ end
438
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Durable
5
+ # Deletes old terminal runs: one scheduled call to `ActiveJob::Durable.clean_up`.
6
+ # Add it to your schedule, e.g., Solid Queue:
7
+ #
8
+ # # config/recurring.yml
9
+ # durable_housekeeping:
10
+ # class: ActiveJob::Durable::HousekeepingJob
11
+ # schedule: every hour
12
+ class HousekeepingJob < ActiveJob::Base # rubocop:disable Rails/ApplicationJob
13
+ def perform = Durable.clean_up
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module ActiveJob
6
+ module Durable
7
+ # Base class for the gem's records. Can be used to define a custom database to connect to:
8
+ #
9
+ # ActiveJob::Durable.connects_to = { database: { writing: :durable } }
10
+ class Record < ActiveRecord::Base # rubocop:disable Rails/ApplicationRecord
11
+ self.abstract_class = true
12
+ self.strict_loading_by_default = false
13
+
14
+ connects_to(**Durable.connects_to) if Durable.connects_to
15
+ end
16
+ end
17
+ end