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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +380 -13
- data/db/durable_schema.rb +57 -0
- data/lib/active_job/continuable/callbacks.rb +48 -0
- data/lib/active_job/continuable/configurable.rb +75 -0
- data/lib/active_job/continuation/callbacks.rb +18 -0
- data/lib/active_job/continuation/rescue_handlers_first.rb +29 -0
- data/lib/active_job/durable/args_mapper.rb +41 -0
- data/lib/active_job/durable/config.rb +100 -0
- data/lib/active_job/durable/continuation.rb +24 -0
- data/lib/active_job/durable/execution.rb +438 -0
- data/lib/active_job/durable/housekeeping_job.rb +16 -0
- data/lib/active_job/durable/record.rb +17 -0
- data/lib/active_job/durable/run.rb +165 -0
- data/lib/active_job/durable/step.rb +14 -0
- data/lib/active_job/durable/wake_job.rb +16 -0
- data/lib/active_job/durable.rb +251 -0
- data/lib/ajdc/railtie.rb +18 -0
- data/lib/ajdc/version.rb +1 -1
- data/lib/ajdc.rb +1 -0
- data/lib/generators/ajdc/install/USAGE +19 -0
- data/lib/generators/ajdc/install/install_generator.rb +66 -0
- data/lib/generators/ajdc/install/templates/create_active_job_durable_tables.rb.tt +56 -0
- data/lib/generators/ajdc/install/templates/initializer.rb.tt +4 -0
- data/skills/ajdc/SKILL.md +117 -0
- data/skills/ajdc/examples/README.md +11 -0
- data/skills/ajdc/examples/agent-loop.md +57 -0
- data/skills/ajdc/examples/halting.md +84 -0
- data/skills/ajdc/examples/pipeline.md +73 -0
- data/skills/ajdc/examples/signals.md +139 -0
- data/skills/ajdc/examples/timers.md +136 -0
- data/skills/ajdc/examples/uniqueness.md +87 -0
- data/skills/ajdc/references/api.md +157 -0
- data/skills/ajdc/references/decision-guide.md +130 -0
- data/skills/ajdc/references/installation.md +98 -0
- data/skills/ajdc/references/testing.md +152 -0
- data/skills/ajdc/references/transactions.md +68 -0
- 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
|