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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/audit-clock-drift/SKILL.md +11 -0
  3. data/.claude/skills/audit-database-indexes/SKILL.md +11 -0
  4. data/.claude/skills/audit-database-support/SKILL.md +18 -0
  5. data/.claude/skills/audit-durability/SKILL.md +17 -0
  6. data/.saturnci/database.yml +1 -0
  7. data/CHANGELOG-PRO.md +72 -0
  8. data/CHANGELOG.md +148 -1
  9. data/CLAUDE.md +34 -0
  10. data/README.md +24 -14
  11. data/app/controllers/ductwork/application_controller.rb +3 -3
  12. data/app/controllers/ductwork/dashboards_controller.rb +1 -1
  13. data/app/controllers/ductwork/pipelines_controller.rb +4 -3
  14. data/app/helpers/ductwork/application_helper.rb +2 -2
  15. data/app/views/ductwork/dashboards/show.html.erb +12 -12
  16. data/app/views/ductwork/pipelines/index.html.erb +12 -12
  17. data/app/views/ductwork/pipelines/show.html.erb +13 -13
  18. data/app/views/ductwork/step_errors/index.html.erb +2 -2
  19. data/lib/ductwork/branch_claim.rb +185 -0
  20. data/lib/ductwork/cli.rb +73 -24
  21. data/lib/ductwork/configuration.rb +73 -3
  22. data/lib/ductwork/context.rb +17 -19
  23. data/lib/ductwork/database_clock.rb +84 -0
  24. data/lib/ductwork/dsl/branch_builder.rb +46 -1
  25. data/lib/ductwork/dsl/definition_builder.rb +101 -2
  26. data/lib/ductwork/{job_claim.rb → execution_claim.rb} +7 -6
  27. data/lib/ductwork/fault_injection.rb +35 -0
  28. data/lib/ductwork/migration_helper.rb +1 -1
  29. data/lib/ductwork/models/advancement.rb +48 -0
  30. data/lib/ductwork/models/attempt.rb +9 -0
  31. data/lib/ductwork/models/availability.rb +2 -0
  32. data/lib/ductwork/models/branch.rb +800 -0
  33. data/lib/ductwork/models/branch_link.rb +10 -0
  34. data/lib/ductwork/models/execution.rb +199 -2
  35. data/lib/ductwork/models/job.rb +8 -121
  36. data/lib/ductwork/models/pipeline.rb +152 -255
  37. data/lib/ductwork/models/process.rb +177 -5
  38. data/lib/ductwork/models/result.rb +2 -1
  39. data/lib/ductwork/models/run.rb +119 -1
  40. data/lib/ductwork/models/step.rb +26 -6
  41. data/lib/ductwork/models/transition.rb +15 -0
  42. data/lib/ductwork/models/tuple.rb +1 -4
  43. data/lib/ductwork/models/workflow.rb +3 -0
  44. data/lib/ductwork/optimistic_locking_execution_claim.rb +97 -0
  45. data/lib/ductwork/processes/health_check.rb +87 -0
  46. data/lib/ductwork/processes/job_worker.rb +86 -20
  47. data/lib/ductwork/processes/job_worker_runner.rb +25 -37
  48. data/lib/ductwork/processes/pipeline_advancer.rb +48 -76
  49. data/lib/ductwork/processes/pipeline_advancer_runner.rb +34 -44
  50. data/lib/ductwork/processes/process_supervisor.rb +87 -10
  51. data/lib/ductwork/processes/thread_supervisor.rb +55 -21
  52. data/lib/ductwork/processes/thread_supervisor_runner.rb +8 -5
  53. data/lib/ductwork/processes/worker_health_check.rb +55 -0
  54. data/lib/ductwork/row_locking_execution_claim.rb +77 -0
  55. data/lib/ductwork/testing/rspec.rb +28 -11
  56. data/lib/ductwork/version.rb +1 -1
  57. data/lib/ductwork.rb +5 -0
  58. data/lib/generators/ductwork/install/install_generator.rb +14 -4
  59. data/lib/generators/ductwork/install/templates/config/ductwork.yml +4 -0
  60. data/lib/generators/ductwork/install/templates/db/create_ductwork_advancements.rb +29 -0
  61. data/lib/generators/ductwork/install/templates/db/create_ductwork_attempts.rb +20 -0
  62. data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +18 -5
  63. data/lib/generators/ductwork/install/templates/db/create_ductwork_branch_links.rb +27 -0
  64. data/lib/generators/ductwork/install/templates/db/create_ductwork_branches.rb +37 -0
  65. data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +8 -1
  66. data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +0 -8
  67. data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +2 -0
  68. data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +1 -0
  69. data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +45 -6
  70. data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +20 -5
  71. data/lib/generators/ductwork/install/templates/db/create_ductwork_transitions.rb +43 -0
  72. data/lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb +3 -3
  73. data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_advancements.rb +7 -0
  74. data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_executions.rb +14 -0
  75. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_results.rb +7 -0
  76. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_runs.rb +9 -0
  77. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_transitions.rb +16 -0
  78. data/lib/generators/ductwork/update/templates/db/add_pipeline_started_index_to_ductwork_runs.rb +7 -0
  79. data/lib/generators/ductwork/update/templates/db/add_process_id_to_ductwork_executions.rb +35 -0
  80. data/lib/generators/ductwork/update/templates/db/add_role_to_ductwork_processes.rb +20 -0
  81. data/lib/generators/ductwork/update/templates/db/associate_branches_to_runs.rb +74 -0
  82. data/lib/generators/ductwork/update/templates/db/associate_steps_to_branches.rb +24 -0
  83. data/lib/generators/ductwork/update/templates/db/associate_steps_to_runs.rb +72 -0
  84. data/lib/generators/ductwork/update/templates/db/associate_tuples_to_runs.rb +73 -0
  85. data/lib/generators/ductwork/update/templates/db/backfill_branch_ids_on_steps.rb +21 -0
  86. data/lib/generators/ductwork/update/templates/db/create_ductwork_advancements.rb +28 -0
  87. data/lib/generators/ductwork/update/templates/db/create_ductwork_branch_links.rb +27 -0
  88. data/lib/generators/ductwork/update/templates/db/create_ductwork_branches.rb +37 -0
  89. data/lib/generators/ductwork/update/templates/db/create_ductwork_runs.rb +55 -0
  90. data/lib/generators/ductwork/update/templates/db/create_ductwork_transitions.rb +32 -0
  91. data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb +11 -4
  92. data/lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb +5 -0
  93. data/lib/generators/ductwork/update/templates/db/rename_runs_to_attempts.rb +7 -0
  94. data/lib/generators/ductwork/update/templates/db/update_process_associations.rb +29 -0
  95. data/lib/generators/ductwork/update/update_generator.rb +99 -0
  96. metadata +47 -6
  97. data/lib/ductwork/optimistic_locking_job_claim.rb +0 -88
  98. 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 :steps, class_name: "Ductwork::Step", foreign_key: "pipeline_id", dependent: :destroy
6
- has_many :tuples, class_name: "Ductwork::Tuple", foreign_key: "pipeline_id", dependent: :destroy
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: Time.current,
72
- started_at: Time.current,
73
- last_advanced_at: Time.current
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 = p.steps.create!(
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: Time.current
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 advance!
98
- # NOTE: if we've expanded the pipeline there could be a lot of
99
- # advancing records which may cause memory issues. something to
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
- Ductwork::Record.transaction do
107
- if edges.nil? || edges.all? { |_, attrs| attrs[:to].blank? }
108
- conditionally_complete_pipeline(advancing_ids)
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
- def parsed_definition
116
- @parsed_definition ||= JSON.parse(definition).with_indifferent_access
117
- end
115
+ last_run = runs.order(started_at: :desc).first
118
116
 
119
- def complete!
120
- update!(status: :completed, completed_at: Time.current)
117
+ if last_run.blank?
118
+ raise ReviveError, "Cannot revive pipeline without previous run"
119
+ end
121
120
 
122
- Ductwork.logger.info(
123
- msg: "Pipeline completed",
124
- pipeline_id: id,
125
- role: :pipeline_advancer
126
- )
127
- end
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
- def halt!
130
- update!(status: :halted, halted_at: Time.current)
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
- Ductwork.logger.info(
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 create_step_and_enqueue_job(edge:, input_arg:, node: nil)
142
- status = :in_progress
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
- remaining = steps
168
- .where(status: %w[in_progress pending advancing])
169
- .where.not(id: advancing_ids)
170
- .exists?
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
- if !remaining
173
- complete!
174
- end
175
- end
152
+ new_branch.save!
176
153
 
177
- def advance_to_next_steps_by_type(edges, advancing_ids)
178
- steps.where(id: advancing_ids).update_all(status: :completed, completed_at: Time.current)
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
- if edges.all? { |_, attrs| attrs[:type] == "combine" }
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 advance_non_merging_steps(edge, advancing_ids)
195
- to_transition = edge[:type]
196
- klass = edge[:klass]
197
-
198
- steps.where(id: advancing_ids, klass: klass).find_each do |step|
199
- if to_transition.in?(%w[chain divide])
200
- advance_to_next_steps(step.id, edge)
201
- elsif to_transition == "expand"
202
- expand_to_next_steps(step.id, edge)
203
- else
204
- Ductwork.logger.error(
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 advance_to_next_steps(step_id, edge)
215
- too_many = edge[:to].tally.any? do |to_klass, count|
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
- def conditionally_combine_next_steps(edges, advancing_ids)
234
- if steps.where(status: %w[pending in_progress], node: edges.keys).none?
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
- Ductwork.logger.debug(
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 combine_next_steps(edges, advancing_ids)
246
- edge = edges.values.sample
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
- def expand_to_next_steps(step_id, edge)
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
- def bulk_create_steps_and_jobs(edge:, return_value:) # rubocop:todo Metrics
279
- # NOTE: "chain" is used by ActiveRecord so we have to call
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
- Array(return_value).each_slice(1_000).each do |batch| # rubocop:todo Metrics/BlockLength
287
- step_rows = []
288
- job_rows = []
289
- execution_rows = []
290
- availability_rows = []
291
-
292
- batch.each do |value| # rubocop:todo Metrics/BlockLength
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
- Ductwork::Step.insert_all!(step_rows)
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 conditionally_collapse_next_steps(node, edge, advancing_ids)
350
- if steps.where(status: %w[pending in_progress], node: node).none?
351
- collapse_next_steps(edge, advancing_ids)
352
- else
353
- Ductwork.logger.debug(
354
- msg: "Not all expanded steps have completed; not collapsing",
355
- pipeline_id: id,
356
- role: :pipeline_advancer
357
- )
358
- end
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 collapse_next_steps(edge, advancing_ids)
362
- input_arg = []
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
- Ductwork::Job.where(step_id: advancing_ids).find_each do |job|
365
- input_arg << job.return_value
366
- end
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
- create_step_and_enqueue_job(edge:, input_arg:)
252
+ new_tuple.save!
253
+ end
254
+ end
369
255
  end
370
256
 
371
- def log_pipeline_advanced(edges)
372
- Ductwork.logger.info(
373
- msg: "Pipeline advanced",
374
- pipeline_id: id,
375
- transitions: edges.map { |_, v| v.dig(-1, :type) },
376
- role: :pipeline_advancer
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
- class NotFoundError < StandardError; end
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
- def self.report_heartbeat!
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
- find_by!(pid:, machine_identifier:)
14
- .update!(last_heartbeat_at: Time.current)
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
- raise NotFoundError, "Process #{pid} not found"
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