ductwork 0.25.0 → 1.0.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/.claude/skills/audit-clock-drift/SKILL.md +11 -0
- data/.claude/skills/audit-database-indexes/SKILL.md +11 -0
- data/.claude/skills/audit-database-support/SKILL.md +18 -0
- data/.claude/skills/audit-durability/SKILL.md +17 -0
- data/.saturnci/database.yml +1 -0
- data/CHANGELOG-PRO.md +72 -0
- data/CHANGELOG.md +148 -1
- data/CLAUDE.md +34 -0
- data/README.md +24 -14
- data/app/controllers/ductwork/application_controller.rb +3 -3
- data/app/controllers/ductwork/dashboards_controller.rb +1 -1
- data/app/controllers/ductwork/pipelines_controller.rb +4 -3
- data/app/helpers/ductwork/application_helper.rb +2 -2
- data/app/views/ductwork/dashboards/show.html.erb +12 -12
- data/app/views/ductwork/pipelines/index.html.erb +12 -12
- data/app/views/ductwork/pipelines/show.html.erb +13 -13
- data/app/views/ductwork/step_errors/index.html.erb +2 -2
- data/lib/ductwork/branch_claim.rb +185 -0
- data/lib/ductwork/cli.rb +73 -24
- data/lib/ductwork/configuration.rb +73 -3
- data/lib/ductwork/context.rb +17 -19
- data/lib/ductwork/database_clock.rb +84 -0
- data/lib/ductwork/dsl/branch_builder.rb +46 -1
- data/lib/ductwork/dsl/definition_builder.rb +101 -2
- data/lib/ductwork/{job_claim.rb → execution_claim.rb} +7 -6
- data/lib/ductwork/fault_injection.rb +35 -0
- data/lib/ductwork/migration_helper.rb +1 -1
- data/lib/ductwork/models/advancement.rb +48 -0
- data/lib/ductwork/models/attempt.rb +9 -0
- data/lib/ductwork/models/availability.rb +2 -0
- data/lib/ductwork/models/branch.rb +800 -0
- data/lib/ductwork/models/branch_link.rb +10 -0
- data/lib/ductwork/models/execution.rb +199 -2
- data/lib/ductwork/models/job.rb +8 -121
- data/lib/ductwork/models/pipeline.rb +152 -255
- data/lib/ductwork/models/process.rb +177 -5
- data/lib/ductwork/models/result.rb +2 -1
- data/lib/ductwork/models/run.rb +119 -1
- data/lib/ductwork/models/step.rb +26 -6
- data/lib/ductwork/models/transition.rb +15 -0
- data/lib/ductwork/models/tuple.rb +1 -4
- data/lib/ductwork/models/workflow.rb +3 -0
- data/lib/ductwork/optimistic_locking_execution_claim.rb +97 -0
- data/lib/ductwork/processes/health_check.rb +87 -0
- data/lib/ductwork/processes/job_worker.rb +86 -20
- data/lib/ductwork/processes/job_worker_runner.rb +25 -37
- data/lib/ductwork/processes/pipeline_advancer.rb +48 -76
- data/lib/ductwork/processes/pipeline_advancer_runner.rb +34 -44
- data/lib/ductwork/processes/process_supervisor.rb +87 -10
- data/lib/ductwork/processes/thread_supervisor.rb +55 -21
- data/lib/ductwork/processes/thread_supervisor_runner.rb +8 -5
- data/lib/ductwork/processes/worker_health_check.rb +55 -0
- data/lib/ductwork/row_locking_execution_claim.rb +77 -0
- data/lib/ductwork/testing/rspec.rb +28 -11
- data/lib/ductwork/version.rb +1 -1
- data/lib/ductwork.rb +5 -0
- data/lib/generators/ductwork/install/install_generator.rb +14 -4
- data/lib/generators/ductwork/install/templates/config/ductwork.yml +4 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_advancements.rb +29 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_attempts.rb +20 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +18 -5
- data/lib/generators/ductwork/install/templates/db/create_ductwork_branch_links.rb +27 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_branches.rb +37 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +8 -1
- data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +0 -8
- data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +2 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +1 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +45 -6
- data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +20 -5
- data/lib/generators/ductwork/install/templates/db/create_ductwork_transitions.rb +43 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb +3 -3
- data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_advancements.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_executions.rb +14 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_results.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_runs.rb +9 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_transitions.rb +16 -0
- data/lib/generators/ductwork/update/templates/db/add_pipeline_started_index_to_ductwork_runs.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_process_id_to_ductwork_executions.rb +35 -0
- data/lib/generators/ductwork/update/templates/db/add_role_to_ductwork_processes.rb +20 -0
- data/lib/generators/ductwork/update/templates/db/associate_branches_to_runs.rb +74 -0
- data/lib/generators/ductwork/update/templates/db/associate_steps_to_branches.rb +24 -0
- data/lib/generators/ductwork/update/templates/db/associate_steps_to_runs.rb +72 -0
- data/lib/generators/ductwork/update/templates/db/associate_tuples_to_runs.rb +73 -0
- data/lib/generators/ductwork/update/templates/db/backfill_branch_ids_on_steps.rb +21 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_advancements.rb +28 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_branch_links.rb +27 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_branches.rb +37 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_runs.rb +55 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_transitions.rb +32 -0
- data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb +11 -4
- data/lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb +5 -0
- data/lib/generators/ductwork/update/templates/db/rename_runs_to_attempts.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/update_process_associations.rb +29 -0
- data/lib/generators/ductwork/update/update_generator.rb +99 -0
- metadata +47 -6
- data/lib/ductwork/optimistic_locking_job_claim.rb +0 -88
- data/lib/ductwork/row_locking_job_claim.rb +0 -75
|
@@ -2,16 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
class Pipeline < Ductwork::Record # rubocop:todo Metrics/ClassLength
|
|
5
|
-
has_many :
|
|
6
|
-
|
|
5
|
+
has_many :runs,
|
|
6
|
+
class_name: "Ductwork::Run",
|
|
7
|
+
foreign_key: "pipeline_id",
|
|
8
|
+
dependent: :destroy
|
|
7
9
|
|
|
8
10
|
validates :klass, presence: true
|
|
9
|
-
validates :definition, presence: true
|
|
10
|
-
validates :definition_sha1, presence: true
|
|
11
11
|
validates :status, presence: true
|
|
12
|
-
validates :started_at, presence: true
|
|
13
|
-
validates :triggered_at, presence: true
|
|
14
|
-
validates :last_advanced_at, presence: true
|
|
15
12
|
|
|
16
13
|
enum :status,
|
|
17
14
|
pending: "pending",
|
|
@@ -31,6 +28,7 @@ module Ductwork
|
|
|
31
28
|
end
|
|
32
29
|
|
|
33
30
|
class DefinitionError < StandardError; end
|
|
31
|
+
class ReviveError < StandardError; end
|
|
34
32
|
|
|
35
33
|
class << self
|
|
36
34
|
attr_reader :pipeline_definition
|
|
@@ -53,31 +51,42 @@ module Ductwork
|
|
|
53
51
|
Ductwork.defined_pipelines << name.to_s
|
|
54
52
|
end
|
|
55
53
|
|
|
56
|
-
def trigger(*args)
|
|
54
|
+
def trigger(*args) # rubocop:todo Metrics
|
|
57
55
|
if pipeline_definition.nil?
|
|
58
56
|
raise DefinitionError, "Pipeline must be defined before triggering"
|
|
59
57
|
end
|
|
60
58
|
|
|
59
|
+
now = Time.current
|
|
61
60
|
node = pipeline_definition.dig(:nodes, 0)
|
|
62
61
|
klass = pipeline_definition.dig(:edges, node, :klass)
|
|
63
62
|
definition = JSON.dump(pipeline_definition)
|
|
64
63
|
|
|
65
|
-
pipeline = Record.transaction do
|
|
64
|
+
pipeline = Record.transaction do # rubocop:todo Metrics/BlockLength
|
|
66
65
|
p = create!(
|
|
67
66
|
klass: name.to_s,
|
|
67
|
+
status: :in_progress
|
|
68
|
+
)
|
|
69
|
+
run = p.runs.create!(
|
|
70
|
+
pipeline_klass: name.to_s,
|
|
68
71
|
status: :in_progress,
|
|
69
72
|
definition: definition,
|
|
70
73
|
definition_sha1: Digest::SHA1.hexdigest(definition),
|
|
71
|
-
triggered_at:
|
|
72
|
-
started_at:
|
|
73
|
-
|
|
74
|
+
triggered_at: now,
|
|
75
|
+
started_at: now
|
|
76
|
+
)
|
|
77
|
+
branch = run.branches.create!(
|
|
78
|
+
pipeline_klass: name.to_s,
|
|
79
|
+
status: :in_progress,
|
|
80
|
+
started_at: now,
|
|
81
|
+
last_advanced_at: now
|
|
74
82
|
)
|
|
75
|
-
step =
|
|
83
|
+
step = branch.steps.create!(
|
|
84
|
+
run: run,
|
|
76
85
|
node: node,
|
|
77
86
|
klass: klass,
|
|
78
87
|
status: :in_progress,
|
|
79
88
|
to_transition: :start,
|
|
80
|
-
started_at:
|
|
89
|
+
started_at: now
|
|
81
90
|
)
|
|
82
91
|
Ductwork::Job.enqueue(step, *args)
|
|
83
92
|
|
|
@@ -94,287 +103,175 @@ module Ductwork
|
|
|
94
103
|
end
|
|
95
104
|
end
|
|
96
105
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
# watch out for here and maybe add in config to use AR relation
|
|
101
|
-
# at certain counts or even memory limits.
|
|
102
|
-
advancing_steps = steps.advancing.pluck(:id, :node, :klass)
|
|
103
|
-
advancing_ids = advancing_steps.map(&:first)
|
|
104
|
-
edges = find_edges(advancing_steps)
|
|
106
|
+
def current_run
|
|
107
|
+
runs.in_progress.sole
|
|
108
|
+
end
|
|
105
109
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
else
|
|
110
|
-
advance_to_next_steps_by_type(edges, advancing_ids)
|
|
111
|
-
end
|
|
110
|
+
def revive!(duplicate_context: false)
|
|
111
|
+
if !halted?
|
|
112
|
+
raise ReviveError, "Cannot revive #{status} pipeline"
|
|
112
113
|
end
|
|
113
|
-
end
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
@parsed_definition ||= JSON.parse(definition).with_indifferent_access
|
|
117
|
-
end
|
|
115
|
+
last_run = runs.order(started_at: :desc).first
|
|
118
116
|
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
if last_run.blank?
|
|
118
|
+
raise ReviveError, "Cannot revive pipeline without previous run"
|
|
119
|
+
end
|
|
121
120
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
121
|
+
now = Time.current
|
|
122
|
+
new_run = last_run.dup
|
|
123
|
+
new_run.triggered_at = now
|
|
124
|
+
new_run.started_at = now
|
|
125
|
+
new_run.status = "in_progress"
|
|
126
|
+
new_run.on_halt_dispatched_at = nil
|
|
128
127
|
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
Ductwork::Record.transaction do
|
|
129
|
+
lock!
|
|
130
|
+
new_run.save!
|
|
131
|
+
duplicate_successful_branches_and_steps(new_run, last_run, now)
|
|
132
|
+
duplicate_halted_branches_and_steps(new_run, last_run, now)
|
|
133
|
+
conditionally_duplicate_context(last_run, new_run, now, duplicate_context)
|
|
134
|
+
duplicate_all_branch_links(new_run, last_run)
|
|
135
|
+
in_progress!
|
|
136
|
+
end
|
|
131
137
|
|
|
132
|
-
|
|
133
|
-
msg: "Pipeline halted",
|
|
134
|
-
pipeline_id: id,
|
|
135
|
-
pipeline_klass: klass
|
|
136
|
-
)
|
|
138
|
+
self
|
|
137
139
|
end
|
|
138
140
|
|
|
139
141
|
private
|
|
140
142
|
|
|
141
|
-
def
|
|
142
|
-
status =
|
|
143
|
-
started_at = Time.current
|
|
144
|
-
# NOTE: "chain" is used by ActiveRecord so we have to call
|
|
145
|
-
# this enum value "default" :sad:
|
|
146
|
-
to_transition = edge[:type] == "chain" ? "default" : edge[:type]
|
|
147
|
-
node ||= edge[:to].sole
|
|
148
|
-
klass = parsed_definition.dig(:edges, node, :klass)
|
|
149
|
-
|
|
150
|
-
next_step = steps.create!(node:, klass:, status:, to_transition:, started_at:)
|
|
151
|
-
Ductwork::Job.enqueue(next_step, input_arg)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def find_edges(advancing_steps)
|
|
155
|
-
if advancing_steps.any?
|
|
156
|
-
nodes = advancing_steps.map(&:second)
|
|
157
|
-
|
|
158
|
-
parsed_definition.fetch(:edges, {}).select { |k| k.in?(nodes) }
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def conditionally_complete_pipeline(advancing_ids)
|
|
163
|
-
steps
|
|
164
|
-
.where(id: advancing_ids)
|
|
165
|
-
.update_all(status: :completed, completed_at: Time.current)
|
|
143
|
+
def duplicate_successful_branches_and_steps(new_run, last_run, now)
|
|
144
|
+
status = %i[advancing waiting completed]
|
|
166
145
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
146
|
+
last_run.branches.where(status:).find_each do |branch|
|
|
147
|
+
new_branch = branch.dup
|
|
148
|
+
new_branch.run = new_run
|
|
149
|
+
new_branch.started_at = now
|
|
150
|
+
new_branch.completed_at = now
|
|
171
151
|
|
|
172
|
-
|
|
173
|
-
complete!
|
|
174
|
-
end
|
|
175
|
-
end
|
|
152
|
+
new_branch.save!
|
|
176
153
|
|
|
177
|
-
|
|
178
|
-
|
|
154
|
+
branch.steps.where(status:).find_each do |step|
|
|
155
|
+
new_step = step.dup
|
|
156
|
+
new_step.source_step = step
|
|
157
|
+
new_step.branch = new_branch
|
|
158
|
+
new_step.run = new_run
|
|
159
|
+
new_step.started_at = now
|
|
160
|
+
new_step.completed_at = now
|
|
179
161
|
|
|
180
|
-
|
|
181
|
-
conditionally_combine_next_steps(edges, advancing_ids)
|
|
182
|
-
else
|
|
183
|
-
edges.each do |node, attrs|
|
|
184
|
-
if attrs[:type] == "collapse"
|
|
185
|
-
conditionally_collapse_next_steps(node, attrs, advancing_ids)
|
|
186
|
-
else
|
|
187
|
-
advance_non_merging_steps(attrs, advancing_ids)
|
|
188
|
-
end
|
|
162
|
+
new_step.save!
|
|
189
163
|
end
|
|
190
164
|
end
|
|
191
|
-
log_pipeline_advanced(edges)
|
|
192
165
|
end
|
|
193
166
|
|
|
194
|
-
def
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
msg: "Invalid To Transition",
|
|
206
|
-
to_transition: to_transition,
|
|
207
|
-
pipeline_id: id,
|
|
208
|
-
role: :pipeline_advancer
|
|
209
|
-
)
|
|
210
|
-
end
|
|
167
|
+
def duplicate_halted_branches_and_steps(new_run, last_run, now)
|
|
168
|
+
last_run.branches.where(status: :halted).find_each do |branch|
|
|
169
|
+
new_branch = branch.dup
|
|
170
|
+
new_branch.run = new_run
|
|
171
|
+
new_branch.status = "in_progress"
|
|
172
|
+
new_branch.started_at = now
|
|
173
|
+
new_branch.completed_at = nil
|
|
174
|
+
new_branch.last_advanced_at = now
|
|
175
|
+
|
|
176
|
+
new_branch.save!
|
|
177
|
+
revive_branch_steps(branch, new_branch, new_run, now)
|
|
211
178
|
end
|
|
212
179
|
end
|
|
213
180
|
|
|
214
|
-
def
|
|
215
|
-
|
|
216
|
-
depth = Ductwork
|
|
217
|
-
.configuration
|
|
218
|
-
.steps_max_depth(pipeline: klass, step: to_klass)
|
|
219
|
-
|
|
220
|
-
depth != -1 && count > depth
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
if too_many
|
|
224
|
-
halt!
|
|
225
|
-
else
|
|
226
|
-
edge[:to].each do |node|
|
|
227
|
-
input_arg = Ductwork::Job.find_by(step_id:).return_value
|
|
228
|
-
create_step_and_enqueue_job(edge:, input_arg:, node:)
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
end
|
|
181
|
+
def revive_branch_steps(branch, new_branch, new_run, now)
|
|
182
|
+
duplicate_prior_steps_as_completed(branch, new_branch, new_run, now)
|
|
232
183
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
combine_next_steps(edges, advancing_ids)
|
|
184
|
+
if branch.job_retries_exhausted?
|
|
185
|
+
re_enqueue_failed_step(branch, new_branch, new_run, now)
|
|
236
186
|
else
|
|
237
|
-
|
|
238
|
-
msg: "Not all divided steps have completed; not combining",
|
|
239
|
-
pipeline_id: id,
|
|
240
|
-
role: :pipeline_advancer
|
|
241
|
-
)
|
|
187
|
+
re_advance_latest_step(branch, new_branch, new_run, now)
|
|
242
188
|
end
|
|
243
189
|
end
|
|
244
190
|
|
|
245
|
-
def
|
|
246
|
-
|
|
247
|
-
groups = steps
|
|
248
|
-
.where(id: advancing_ids)
|
|
249
|
-
.group(:node)
|
|
250
|
-
.count
|
|
251
|
-
.keys
|
|
252
|
-
.map { |node| steps.where(id: advancing_ids).where(node:) }
|
|
253
|
-
|
|
254
|
-
groups.first.zip(*groups[1..]).each do |group|
|
|
255
|
-
input_arg = Ductwork::Job
|
|
256
|
-
.where(step_id: group.map(&:id))
|
|
257
|
-
.map(&:return_value)
|
|
258
|
-
create_step_and_enqueue_job(edge:, input_arg:)
|
|
259
|
-
end
|
|
260
|
-
end
|
|
191
|
+
def duplicate_prior_steps_as_completed(branch, new_branch, new_run, now)
|
|
192
|
+
latest_step = branch.steps.order(started_at: :desc).first
|
|
261
193
|
|
|
262
|
-
|
|
263
|
-
next_klass = parsed_definition.dig(:edges, edge[:to].sole, :klass)
|
|
264
|
-
return_value = Ductwork::Job
|
|
265
|
-
.find_by(step_id:)
|
|
266
|
-
.return_value
|
|
267
|
-
max_depth = Ductwork.configuration.steps_max_depth(pipeline: klass, step: next_klass)
|
|
268
|
-
|
|
269
|
-
if max_depth != -1 && return_value.count > max_depth
|
|
270
|
-
halt!
|
|
271
|
-
elsif return_value.none?
|
|
272
|
-
complete!
|
|
273
|
-
else
|
|
274
|
-
bulk_create_steps_and_jobs(edge:, return_value:)
|
|
275
|
-
end
|
|
276
|
-
end
|
|
194
|
+
return if latest_step.blank?
|
|
277
195
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
# this enum value "default" :sad:
|
|
281
|
-
to_transition = edge[:type] == "chain" ? "default" : edge[:type]
|
|
282
|
-
node ||= edge[:to].sole
|
|
283
|
-
step_klass = parsed_definition.dig(:edges, node, :klass)
|
|
284
|
-
now = Time.current
|
|
196
|
+
status = %i[advancing waiting completed]
|
|
197
|
+
id = latest_step.id
|
|
285
198
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
step_id = SecureRandom.uuid_v7
|
|
294
|
-
job_id = SecureRandom.uuid_v7
|
|
295
|
-
execution_id = SecureRandom.uuid_v7
|
|
296
|
-
availability_id = SecureRandom.uuid_v7
|
|
297
|
-
|
|
298
|
-
step_rows << {
|
|
299
|
-
id: step_id,
|
|
300
|
-
pipeline_id: id,
|
|
301
|
-
node: node,
|
|
302
|
-
klass: step_klass,
|
|
303
|
-
status: "in_progress",
|
|
304
|
-
to_transition: to_transition,
|
|
305
|
-
started_at: now,
|
|
306
|
-
created_at: now,
|
|
307
|
-
updated_at: now,
|
|
308
|
-
}
|
|
309
|
-
job_rows << {
|
|
310
|
-
id: job_id,
|
|
311
|
-
step_id: step_id,
|
|
312
|
-
input_args: JSON.dump({ args: [value] }),
|
|
313
|
-
klass: step_klass,
|
|
314
|
-
started_at: now,
|
|
315
|
-
created_at: now,
|
|
316
|
-
updated_at: now,
|
|
317
|
-
}
|
|
318
|
-
execution_rows << {
|
|
319
|
-
id: execution_id,
|
|
320
|
-
job_id: job_id,
|
|
321
|
-
retry_count: 0,
|
|
322
|
-
started_at: now,
|
|
323
|
-
created_at: now,
|
|
324
|
-
updated_at: now,
|
|
325
|
-
}
|
|
326
|
-
availability_rows << {
|
|
327
|
-
id: availability_id,
|
|
328
|
-
execution_id: execution_id,
|
|
329
|
-
pipeline_klass: klass,
|
|
330
|
-
started_at: now,
|
|
331
|
-
created_at: now,
|
|
332
|
-
updated_at: now,
|
|
333
|
-
}
|
|
334
|
-
end
|
|
199
|
+
branch.steps.where(status:).where.not(id:).find_each do |step|
|
|
200
|
+
new_step = step.dup
|
|
201
|
+
new_step.source_step = step
|
|
202
|
+
new_step.branch = new_branch
|
|
203
|
+
new_step.run = new_run
|
|
204
|
+
new_step.started_at = now
|
|
205
|
+
new_step.completed_at = now
|
|
335
206
|
|
|
336
|
-
|
|
337
|
-
Ductwork::Job.insert_all!(job_rows)
|
|
338
|
-
Ductwork::Execution.insert_all!(execution_rows)
|
|
339
|
-
Ductwork::Availability.insert_all!(availability_rows)
|
|
340
|
-
|
|
341
|
-
Ductwork.logger.info(
|
|
342
|
-
msg: "Job batch enqueued",
|
|
343
|
-
count: batch.count,
|
|
344
|
-
job_klass: step_klass
|
|
345
|
-
)
|
|
207
|
+
new_step.save!
|
|
346
208
|
end
|
|
347
209
|
end
|
|
348
210
|
|
|
349
|
-
def
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
211
|
+
def re_enqueue_failed_step(branch, new_branch, new_run, now)
|
|
212
|
+
failed_step = branch.steps.find_by(status: :failed)
|
|
213
|
+
args = JSON.parse(failed_step.job.input_args).fetch("args")
|
|
214
|
+
step = new_branch.steps.create!(
|
|
215
|
+
run: new_run,
|
|
216
|
+
source_step: failed_step,
|
|
217
|
+
node: failed_step.node,
|
|
218
|
+
klass: failed_step.klass,
|
|
219
|
+
to_transition: failed_step.to_transition,
|
|
220
|
+
status: :in_progress,
|
|
221
|
+
started_at: now
|
|
222
|
+
)
|
|
223
|
+
Ductwork::Job.enqueue(step, *args)
|
|
359
224
|
end
|
|
360
225
|
|
|
361
|
-
def
|
|
362
|
-
|
|
226
|
+
def re_advance_latest_step(branch, new_branch, new_run, now)
|
|
227
|
+
latest_step = branch.steps.order(started_at: :desc).first
|
|
228
|
+
step = new_branch.steps.create!(
|
|
229
|
+
run: new_run,
|
|
230
|
+
source_step: latest_step,
|
|
231
|
+
node: latest_step.node,
|
|
232
|
+
klass: latest_step.klass,
|
|
233
|
+
to_transition: latest_step.to_transition,
|
|
234
|
+
status: :advancing,
|
|
235
|
+
started_at: now
|
|
236
|
+
)
|
|
237
|
+
job = latest_step.job.dup
|
|
238
|
+
job.step = step
|
|
239
|
+
job.started_at = now
|
|
240
|
+
job.completed_at = now
|
|
241
|
+
job.save!
|
|
242
|
+
end
|
|
363
243
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
244
|
+
def conditionally_duplicate_context(last_run, new_run, now, duplicate_context)
|
|
245
|
+
if duplicate_context
|
|
246
|
+
last_run.tuples.find_each do |tuple|
|
|
247
|
+
new_tuple = tuple.dup
|
|
248
|
+
new_tuple.run = new_run
|
|
249
|
+
new_tuple.first_set_at = now
|
|
250
|
+
new_tuple.last_set_at = now
|
|
367
251
|
|
|
368
|
-
|
|
252
|
+
new_tuple.save!
|
|
253
|
+
end
|
|
254
|
+
end
|
|
369
255
|
end
|
|
370
256
|
|
|
371
|
-
def
|
|
372
|
-
Ductwork
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
257
|
+
def duplicate_all_branch_links(new_run, last_run)
|
|
258
|
+
map = Ductwork::Step
|
|
259
|
+
.where(branch_id: new_run.branches.select(:id))
|
|
260
|
+
.where.not(source_step_id: nil)
|
|
261
|
+
.joins("INNER JOIN ductwork_steps src ON src.id = ductwork_steps.source_step_id")
|
|
262
|
+
.pluck("src.branch_id", "ductwork_steps.branch_id")
|
|
263
|
+
.uniq
|
|
264
|
+
.to_h
|
|
265
|
+
old_branch_ids = last_run.branches.select(:id)
|
|
266
|
+
|
|
267
|
+
Ductwork::BranchLink
|
|
268
|
+
.where(parent_branch_id: old_branch_ids, child_branch_id: old_branch_ids)
|
|
269
|
+
.find_each do |old_link|
|
|
270
|
+
Ductwork::BranchLink.create!(
|
|
271
|
+
parent_branch_id: map.fetch(old_link.parent_branch_id),
|
|
272
|
+
child_branch_id: map.fetch(old_link.child_branch_id)
|
|
273
|
+
)
|
|
274
|
+
end
|
|
378
275
|
end
|
|
379
276
|
end
|
|
380
277
|
end
|
|
@@ -2,18 +2,190 @@
|
|
|
2
2
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
class Process < Ductwork::Record
|
|
5
|
-
|
|
5
|
+
has_many :advancements,
|
|
6
|
+
class_name: "Ductwork::Advancement",
|
|
7
|
+
foreign_key: "process_id",
|
|
8
|
+
dependent: :nullify
|
|
9
|
+
has_many :availabilities,
|
|
10
|
+
class_name: "Ductwork::Availability",
|
|
11
|
+
foreign_key: "process_id",
|
|
12
|
+
dependent: :nullify
|
|
13
|
+
has_many :executions,
|
|
14
|
+
class_name: "Ductwork::Execution",
|
|
15
|
+
foreign_key: "process_id",
|
|
16
|
+
dependent: :nullify
|
|
6
17
|
|
|
7
18
|
validates :pid, uniqueness: { scope: :machine_identifier }
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
enum :role,
|
|
21
|
+
supervisor: "supervisor",
|
|
22
|
+
pipeline_advancer: "pipeline_advancer",
|
|
23
|
+
job_worker: "job_worker"
|
|
24
|
+
|
|
25
|
+
ORPHANED_CLAIM_SWEEP_MULTIPLIER = 3
|
|
26
|
+
|
|
27
|
+
def self.adopt_or_create_current!(role)
|
|
10
28
|
pid = ::Process.pid
|
|
11
29
|
machine_identifier = Ductwork::MachineIdentifier.fetch
|
|
30
|
+
last_heartbeat_at = Ductwork::DatabaseClock.now
|
|
31
|
+
existing = Ductwork::Process.find_by(pid:, machine_identifier:)
|
|
32
|
+
|
|
33
|
+
# NOTE: Same pid + machine_identifier can only mean the OS reused this
|
|
34
|
+
# pid, which happens after the prior process at this identity has
|
|
35
|
+
# exited -- a live process can never be replaced while still running.
|
|
36
|
+
# So its in-flight claims belong to a dead incarnation and must always
|
|
37
|
+
# be recovered here, even when the heartbeat still looks fresh because
|
|
38
|
+
# the reaper's timeout hasn't elapsed yet.
|
|
39
|
+
existing&.recover_crashed_claims!(role)
|
|
40
|
+
|
|
41
|
+
Ductwork::Process
|
|
42
|
+
.find_or_initialize_by(pid:, machine_identifier:)
|
|
43
|
+
.tap { |process| process.update!(last_heartbeat_at:, role:) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.current
|
|
47
|
+
pid = ::Process.pid
|
|
48
|
+
machine_identifier = Ductwork::MachineIdentifier.fetch
|
|
49
|
+
|
|
50
|
+
find_by(pid:, machine_identifier:)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.reap_all!(role)
|
|
54
|
+
count = 0
|
|
55
|
+
timeout = Ductwork.configuration.supervisor_reaper_timeout
|
|
56
|
+
sql = Ductwork::DatabaseClock.ago_sql("last_heartbeat_at", timeout)
|
|
57
|
+
|
|
58
|
+
Ductwork.logger.debug(
|
|
59
|
+
msg: "Reaping orphaned process records",
|
|
60
|
+
role: role
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
where(sql).find_each do |process|
|
|
64
|
+
process.reap!(role)
|
|
65
|
+
count += 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Ductwork.logger.debug(
|
|
69
|
+
msg: "Reaped #{count} orphaned process records",
|
|
70
|
+
count: count,
|
|
71
|
+
role: role
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# NOTE: backstop for claims that lost their process_id (dependent: :nullify
|
|
76
|
+
# on a process destroy racing a fresh claim) and so are unreachable through
|
|
77
|
+
# any Process association -- `reap!`/`reap_all!` above only ever look
|
|
78
|
+
# through a Process record's own advancements/executions, and a nil
|
|
79
|
+
# process_id means there is no Process record to look through. This scans
|
|
80
|
+
# the orphaned rows directly instead.
|
|
81
|
+
#
|
|
82
|
+
# Executions are additionally scoped to availabilities with a completed_at
|
|
83
|
+
# present: an unclaimed execution legitimately has process_id: nil while
|
|
84
|
+
# it waits to be picked up (see RowLockingExecutionClaim#claim_availability,
|
|
85
|
+
# which only sets process_id at claim time), so process_id: nil alone
|
|
86
|
+
# would misidentify perfectly healthy queued work as orphaned.
|
|
87
|
+
def self.sweep_orphaned_claims!(role)
|
|
88
|
+
timeout = Ductwork.configuration.supervisor_reaper_timeout * ORPHANED_CLAIM_SWEEP_MULTIPLIER
|
|
89
|
+
count = 0
|
|
90
|
+
advancement_sql = Ductwork::DatabaseClock.ago_sql("started_at", timeout)
|
|
91
|
+
|
|
92
|
+
Ductwork::Advancement
|
|
93
|
+
.where(process_id: nil, completed_at: nil)
|
|
94
|
+
.where(advancement_sql)
|
|
95
|
+
.find_each do |advancement|
|
|
96
|
+
advancement.process_crashed!
|
|
97
|
+
count += 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
execution_sql = Ductwork::DatabaseClock.ago_sql("ductwork_availabilities.completed_at", timeout)
|
|
12
101
|
|
|
13
|
-
|
|
14
|
-
.
|
|
102
|
+
Ductwork::Execution
|
|
103
|
+
.joins(:availability)
|
|
104
|
+
.where(process_id: nil, completed_at: nil)
|
|
105
|
+
.where.not(ductwork_availabilities: { completed_at: nil })
|
|
106
|
+
.where(execution_sql)
|
|
107
|
+
.find_each do |execution|
|
|
108
|
+
execution.crashed!
|
|
109
|
+
count += 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
Ductwork.logger.debug(
|
|
113
|
+
msg: "Swept #{count} orphaned claims",
|
|
114
|
+
count: count,
|
|
115
|
+
role: role
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.report_heartbeat!(role)
|
|
120
|
+
process = current
|
|
121
|
+
|
|
122
|
+
if process.present?
|
|
123
|
+
process.update!(last_heartbeat_at: Ductwork::DatabaseClock.now)
|
|
124
|
+
process
|
|
125
|
+
else
|
|
126
|
+
Ductwork.logger.warn(
|
|
127
|
+
msg: "Process record missing, re-adopting (likely reaped after host suspend)",
|
|
128
|
+
pid: ::Process.pid
|
|
129
|
+
)
|
|
130
|
+
adopt_or_create_current!(role)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def reap!(role, force: false)
|
|
135
|
+
timeout = Ductwork.configuration.supervisor_reaper_timeout
|
|
136
|
+
sql = Ductwork::DatabaseClock.ago_sql("last_heartbeat_at", timeout)
|
|
137
|
+
|
|
138
|
+
Ductwork.logger.debug(
|
|
139
|
+
msg: "Reaping orphaned process record #{id}",
|
|
140
|
+
id: id,
|
|
141
|
+
role: role
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
Ductwork::Record.transaction do
|
|
145
|
+
# NOTE: Callers that have already killed/stopped the process hold proof
|
|
146
|
+
# of death and pass force: true to skip the staleness guard. The row
|
|
147
|
+
# lock and existence check are kept either way to stay atomic and to
|
|
148
|
+
# avoid double-reaping a record another parent already cleaned up
|
|
149
|
+
scope = Ductwork::Process.where(id:).lock
|
|
150
|
+
scope = scope.where(sql) unless force
|
|
151
|
+
|
|
152
|
+
return unless scope.exists?
|
|
153
|
+
|
|
154
|
+
recover_crashed_claims!(role)
|
|
155
|
+
destroy
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Ductwork.logger.debug(
|
|
159
|
+
msg: "Reaped orphaned process record #{id}",
|
|
160
|
+
id: id,
|
|
161
|
+
role: role
|
|
162
|
+
)
|
|
15
163
|
rescue ActiveRecord::RecordNotFound
|
|
16
|
-
|
|
164
|
+
Ductwork.logger.debug(
|
|
165
|
+
msg: "Process already reaped by another parent",
|
|
166
|
+
id: id,
|
|
167
|
+
role: role
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def recover_crashed_claims!(role)
|
|
172
|
+
Ductwork.logger.debug(
|
|
173
|
+
msg: "Recovering in-flight claims on reused process record #{id}",
|
|
174
|
+
id: id,
|
|
175
|
+
role: role
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
Ductwork::Record.transaction do
|
|
179
|
+
advancements.where(completed_at: nil).find_each(&:process_crashed!)
|
|
180
|
+
executions.where(completed_at: nil).find_each(&:crashed!)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def healthy?
|
|
185
|
+
timeout = Ductwork.configuration.supervisor_reaper_timeout
|
|
186
|
+
sql = Ductwork::DatabaseClock.ago_sql("last_heartbeat_at", timeout)
|
|
187
|
+
|
|
188
|
+
self.class.where(id:).where(sql).none?
|
|
17
189
|
end
|
|
18
190
|
end
|
|
19
191
|
end
|