ductwork 0.26.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 +60 -0
  8. data/CHANGELOG.md +144 -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 +6 -1
  25. data/lib/ductwork/dsl/definition_builder.rb +24 -1
  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 +150 -272
  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
@@ -0,0 +1,800 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Branch < Ductwork::Record # rubocop:todo Metrics/ClassLength
5
+ belongs_to :run, class_name: "Ductwork::Run"
6
+ has_many :transitions,
7
+ class_name: "Ductwork::Transition",
8
+ foreign_key: "branch_id",
9
+ dependent: :destroy
10
+ has_many :steps,
11
+ class_name: "Ductwork::Step",
12
+ foreign_key: "branch_id",
13
+ dependent: :destroy
14
+ has_many :parent_junctions,
15
+ class_name: "Ductwork::BranchLink",
16
+ foreign_key: "child_branch_id",
17
+ dependent: :destroy
18
+ has_many :child_junctions,
19
+ class_name: "Ductwork::BranchLink",
20
+ foreign_key: "parent_branch_id",
21
+ dependent: :destroy
22
+ has_many :parent_branches, through: :parent_junctions, source: :parent_branch
23
+ has_many :child_branches, through: :child_junctions, source: :child_branch
24
+
25
+ validates :last_advanced_at, presence: true
26
+ validates :pipeline_klass, presence: true
27
+ validates :status, presence: true
28
+ validates :started_at, presence: true
29
+
30
+ enum :status,
31
+ pending: "pending",
32
+ in_progress: "in_progress",
33
+ waiting: "waiting",
34
+ advancing: "advancing",
35
+ halted: "halted",
36
+ dampened: "dampened",
37
+ completed: "completed"
38
+
39
+ enum :halt_reason,
40
+ job_retries_exhausted: "job_retries_exhausted",
41
+ job_crashes_exhausted: "job_crashes_exhausted",
42
+ advancer_crashes_exhausted: "advancer_crashes_exhausted",
43
+ advancer_retries_exhausted: "advancer_retries_exhausted",
44
+ max_fanout_exceeded: "max_fanout_exceeded",
45
+ condition_unmatched: "condition_unmatched",
46
+ transition_invalid: "transition_invalid"
47
+
48
+ class TransitionError < StandardError; end
49
+
50
+ def self.with_latest_claimed(pipeline_klass)
51
+ branch_claim = Ductwork::BranchClaim.new(pipeline_klass)
52
+ branch = branch_claim.latest
53
+
54
+ if branch.present?
55
+ Ductwork::FaultInjection.checkpoint(:after_branch_claim)
56
+
57
+ yield branch, branch_claim.transition, branch_claim.advancement
58
+
59
+ true
60
+ else
61
+ false
62
+ end
63
+ ensure
64
+ advancement = branch_claim.advancement
65
+
66
+ if advancement&.persisted? && advancement.completed_at.nil?
67
+ advancement.thread_crashed!(branch_claim.token)
68
+ end
69
+ end
70
+
71
+ # NOTE: claim divergence (the reaper released the branch and another advancer
72
+ # reclaimed it) is the expected outcome of a race, not an error, so the fence
73
+ # treats it as such: it logs and returns `false` rather than raising. Every
74
+ # branch/run mutation must run inside the fence. The row is locked for the
75
+ # block's duration, so divergence can only be observed at entry, never
76
+ # mid-block, and nested fences on the same row always hold. Returns `true`
77
+ # when the block ran, `false` when the claim had diverged.
78
+ #
79
+ # NOTE: the fence compares against `@claim_fence_token` (captured in
80
+ # `advance!` before any mutation), NOT the live `claim_token` attribute.
81
+ # `complete!`/`halt!` null the in-memory `claim_token` mid-block; if the
82
+ # surrounding transaction then rolls back (e.g. a deadlock on the run row),
83
+ # the DB restores the real token but the in-memory attribute stays nil.
84
+ # A later fence in the same advancement (the error-recovery path) would then
85
+ # compare the real DB token against `nil`, wrongly conclude the claim
86
+ # diverged, skip recovery, and strand the branch in `advancing`. Anchoring to
87
+ # the captured token still detects a genuine divergence (another advancer
88
+ # reclaimed it => the DB token changed to a value we never held).
89
+ def with_claim_fence(&block)
90
+ fence_token = @claim_fence_token || claim_token
91
+
92
+ Ductwork::Record.transaction do
93
+ if self.class.where(id:).lock.pick(:claim_token) == fence_token
94
+ block.call
95
+ true
96
+ else
97
+ log_claim_diverged
98
+ false
99
+ end
100
+ end
101
+ end
102
+
103
+ def advance!(transition, advancement)
104
+ # NOTE: capture the claim token now, while it is guaranteed live, so the
105
+ # fence can survive an in-block mutation + rollback (see `with_claim_fence`)
106
+ @claim_fence_token = claim_token
107
+ step = latest_step
108
+ max_crash = Ductwork.configuration.pipeline_advancer_max_crash
109
+
110
+ # NOTE: the crash cap is checked first as the true backstop against a
111
+ # poison branch that repeatedly crashes the advancer process/thread. In a
112
+ # normal failed-step halt no advancer crashes have accrued, so this only
113
+ # fires on a genuine crash loop.
114
+ if advancement.crash_count >= max_crash
115
+ halt_branch_and_resolve_run!(transition, advancement, "advancer_crashes_exhausted")
116
+ elsif step.failed?
117
+ halt_branch_and_resolve_run!(transition, advancement, failed_step_halt_reason(step))
118
+ else
119
+ route_by_edge(transition, advancement)
120
+ end
121
+
122
+ # NOTE: this is a no-op unless this advancement just halted the whole run
123
+ run.dispatch_on_halt!
124
+ end
125
+
126
+ def complete!
127
+ update!(
128
+ completed_at: Time.current,
129
+ status: "completed",
130
+ claimed_for_advancing_at: nil,
131
+ claim_token: nil,
132
+ last_advanced_at: Time.current
133
+ )
134
+
135
+ Ductwork.logger.info(
136
+ msg: "Branch completed",
137
+ branch_id: id,
138
+ role: :pipeline_advancer
139
+ )
140
+ end
141
+
142
+ def halt!(halt_reason)
143
+ self.halt_reason = halt_reason
144
+
145
+ update!(
146
+ status: "halted",
147
+ claimed_for_advancing_at: nil,
148
+ claim_token: nil,
149
+ last_advanced_at: Time.current
150
+ )
151
+
152
+ Ductwork.logger.info(
153
+ msg: "Branch halted",
154
+ branch_id: id,
155
+ role: :pipeline_advancer
156
+ )
157
+ end
158
+
159
+ def latest_step
160
+ steps.order(started_at: :desc, id: :desc).limit(1).first
161
+ end
162
+
163
+ # NOTE: the default `expected_token` is the token captured in `advance!`, not
164
+ # the live `claim_token` attribute, for the same reason as `with_claim_fence`:
165
+ # `complete!`/`halt!` null the in-memory attribute mid-transition, and after a
166
+ # rollback (e.g. a run-row deadlock) the DB token is restored while the
167
+ # attribute stays nil. Releasing with the stale nil would match zero rows and
168
+ # silently leave the branch stranded in `advancing`. Callers outside an
169
+ # advancement (fresh branch objects, explicit tokens) are unaffected.
170
+ def release!(expected_token = @claim_fence_token || claim_token)
171
+ Ductwork::Branch
172
+ .where(id: id, claim_token: expected_token, status: :advancing)
173
+ .update_all(
174
+ claimed_for_advancing_at: nil,
175
+ claim_token: nil,
176
+ status: :in_progress,
177
+ last_advanced_at: Time.current
178
+ )
179
+ end
180
+
181
+ private
182
+
183
+ def log_claim_diverged
184
+ Ductwork.logger.info(
185
+ msg: "Branch claim no longer held",
186
+ branch_id: id,
187
+ pipeline_klass: pipeline_klass
188
+ )
189
+ end
190
+
191
+ # NOTE: a failed step exhausted either its error budget (`errored!`) or its
192
+ # crash budget (`crashed!`); both set the step to `failed`, so we read the
193
+ # terminal execution result to report the precise halt reason. yes, another
194
+ # column is prob the better solution here so we don't need to reach in to
195
+ # this data, but the derivation is straightforward, so here we are.
196
+ def failed_step_halt_reason(step)
197
+ if step.terminal_result_type == "process_crashed"
198
+ "job_crashes_exhausted"
199
+ else
200
+ "job_retries_exhausted"
201
+ end
202
+ end
203
+
204
+ def halt_branch_and_resolve_run!(transition, advancement, halt_reason)
205
+ with_claim_fence do
206
+ now = Time.current
207
+ advancement.update!(completed_at: now)
208
+ transition.update!(completed_at: now)
209
+ halt!(halt_reason)
210
+ run.resolve_terminal_state!
211
+ end
212
+ rescue StandardError => e
213
+ # NOTE: re-enter the claim fence before mutating branch/advancement state.
214
+ # A non-stale error can be raised above and, before this rescue runs, the
215
+ # reaper could release the branch and another advancer reclaim it (new
216
+ # token). Without the fence this stale advancer would stomp the live claim.
217
+ # A divergence here is benign (another advancer owns the branch now), so we
218
+ # bail rather than let it surface as an advancer crash.
219
+ fenced = with_claim_fence do
220
+ advancement&.update!(
221
+ completed_at: Time.current,
222
+ error_klass: e.class.to_s,
223
+ error_message: e.message,
224
+ error_backtrace: e.backtrace.join("\n")
225
+ )
226
+ release!
227
+ end
228
+ return unless fenced
229
+
230
+ Ductwork.logger.error(
231
+ msg: "Branch halt errored",
232
+ branch_id: id,
233
+ error_klass: e.class.to_s,
234
+ error_message: e.message
235
+ )
236
+ end
237
+
238
+ def route_by_edge(transition, advancement) # rubocop:todo Metrics
239
+ edge = run.parsed_definition.dig(:edges, latest_step.node)
240
+
241
+ if edge.nil? || edge[:to].blank?
242
+ complete_branch_and_pipeline(transition, advancement)
243
+ elsif edge[:type] == "chain"
244
+ chain_branch(edge, transition, advancement)
245
+ elsif edge[:type] == "collapse"
246
+ collapse_branch(edge, transition, advancement)
247
+ elsif edge[:type] == "combine"
248
+ combine_branch(edge, transition, advancement)
249
+ elsif edge[:type] == "converge"
250
+ converge_branch(edge, transition, advancement)
251
+ elsif edge[:type] == "divert"
252
+ divert_branch(edge, transition, advancement)
253
+ elsif edge[:type] == "divide"
254
+ divide_branch(edge, transition, advancement)
255
+ elsif edge[:type] == "expand"
256
+ expand_branch(edge, transition, advancement)
257
+ else
258
+ raise Ductwork::Branch::TransitionError,
259
+ "Invalid transition type `#{edge[:type]}`"
260
+ end
261
+ rescue StandardError => e
262
+ # NOTE: re-enter the claim fence before mutating branch/run terminal state.
263
+ # A non-stale error can be raised above and, before this rescue runs, the
264
+ # reaper could release the branch and another advancer reclaim it (new
265
+ # token). `halt!` is not token-guarded, so without the fence this stale
266
+ # advancer's halt would stomp the live claim and could halt a run another
267
+ # advancer is actively advancing. A divergence here is benign (another
268
+ # advancer owns the branch now), so we bail.
269
+ fenced = with_claim_fence do # rubocop:todo Metrics/BlockLength
270
+ if e.is_a?(Ductwork::Branch::TransitionError) || too_many_failed_attempts?
271
+ latest_step.update!(status: :completed, completed_at: Time.current)
272
+
273
+ halt_reason = if e.is_a?(Ductwork::Branch::TransitionError)
274
+ "transition_invalid"
275
+ else
276
+ "advancer_retries_exhausted"
277
+ end
278
+ now = Time.current
279
+ advancement&.update!(
280
+ completed_at: now,
281
+ error_klass: e.class.to_s,
282
+ error_message: e.message,
283
+ error_backtrace: e.backtrace.join("\n")
284
+ )
285
+ transition.update!(completed_at: now)
286
+ halt!(halt_reason)
287
+ run.resolve_terminal_state!
288
+ else
289
+ # NOTE: since the transaction rolled back from the error the step is
290
+ # back in the `advancing` status so we don't need to set it here.
291
+ advancement&.update!(
292
+ completed_at: Time.current,
293
+ error_klass: e.class.to_s,
294
+ error_message: e.message,
295
+ error_backtrace: e.backtrace.join("\n")
296
+ )
297
+ release!
298
+ end
299
+ end
300
+ return unless fenced
301
+
302
+ Ductwork.logger.error(
303
+ msg: "Branch advancement errored",
304
+ branch_id: id,
305
+ error_klass: e.class.to_s,
306
+ error_message: e.message
307
+ )
308
+ end
309
+
310
+ def too_many_failed_attempts?
311
+ max = Ductwork.configuration.pipeline_advancer_max_retry
312
+ internal_errors = Ductwork::Advancement::CRASH_ERROR_KLASSES
313
+
314
+ # NOTE: crash/abandonment advancements (a process reaped mid-advancement,
315
+ # or a thread killed) are NOT advancer-logic failures and must not consume
316
+ # the retry budget — otherwise a long, legitimately-resuming `expand` /
317
+ # `collapse` fan-out/fan-in (re-claimed once per crash cycle) is falsely
318
+ # halted as `advancer_retries_exhausted`. This mirrors the execution tier,
319
+ # where `crashed!` consumes a separate `crash_count` budget while only
320
+ # `errored!` consumes `retry_count`; crashes are instead capped by
321
+ # `pipeline_advancer_max_crash` (see `advance!`). Only genuine
322
+ # `StandardError`s raised inside the transition logic (which carry their
323
+ # own `error_klass`) count here.
324
+ transitions
325
+ .joins(:advancements)
326
+ .where(in_step_id: latest_step.id)
327
+ .where.not(ductwork_advancements: { error_klass: nil })
328
+ .where.not(ductwork_advancements: { error_klass: internal_errors })
329
+ .count >= max
330
+ end
331
+
332
+ def complete_branch_and_pipeline(transition, advancement)
333
+ with_claim_fence do
334
+ latest_step.update!(status: :completed, completed_at: Time.current)
335
+ complete!
336
+
337
+ now = Time.current
338
+ advancement.update!(completed_at: now)
339
+ transition.update!(completed_at: now)
340
+
341
+ run.resolve_terminal_state!
342
+ end
343
+ end
344
+
345
+ def chain_branch(edge, transition, advancement)
346
+ input_arg = Ductwork::Job.find_by(step: latest_step).return_value
347
+ node = edge[:to].sole
348
+ klass = run.parsed_definition.dig(:edges, node, :klass)
349
+ started_at = Time.current
350
+
351
+ with_claim_fence do
352
+ latest_step.update!(status: :completed, completed_at: Time.current)
353
+ # NOTE: we stay on the same branch for sequential `chain`-ing
354
+ next_step = steps.create!(
355
+ run: run,
356
+ node: node,
357
+ klass: klass,
358
+ status: "in_progress",
359
+ to_transition: "default",
360
+ started_at: started_at
361
+ )
362
+ Ductwork::Job.enqueue(next_step, input_arg)
363
+
364
+ now = Time.current
365
+ advancement.update!(completed_at: now)
366
+ transition.update!(completed_at: now)
367
+ release!
368
+ end
369
+ end
370
+
371
+ def collapse_branch(edge, transition, advancement) # rubocop:todo Metrics
372
+ parent_branch_id = resolve_fan_in_barrier_branch_id(edge)
373
+
374
+ with_claim_fence do # rubocop:todo Metrics/BlockLength
375
+ # NOTE: lock the parent branch rather than the whole pipeline run
376
+ # because at-most we're only coordinating across child branches of the
377
+ # parent (expanding) branch
378
+ Ductwork::Branch.find(parent_branch_id).lock!
379
+ node = latest_step.node
380
+ latest_step.update!(status: :completed, completed_at: Time.current)
381
+ complete!
382
+
383
+ # NOTE: make this collapsing branch a direct child of the barrier
384
+ # (expanding) branch. In a bare `expand`->`collapse` it already is one,
385
+ # so this no-ops; with intervening transitions (`divide`/`combine`) or
386
+ # nested expands the sibling lives deeper, and without this link the
387
+ # fan-in could neither count nor wire it.
388
+ ensure_fan_in_cohort_link!(parent_branch_id)
389
+
390
+ sibling_ids = collapse_sibling_ids(parent_branch_id, edge, node)
391
+
392
+ if fan_in_complete?(parent_branch_id, edge, sibling_ids)
393
+ input_arg = Ductwork::Job
394
+ .joins(:step)
395
+ .where(ductwork_steps: { branch_id: sibling_ids, node: node })
396
+ .map(&:return_value)
397
+ next_node = edge[:to].sole
398
+ klass = run.parsed_definition.dig(:edges, next_node, :klass)
399
+ started_at = Time.current
400
+ next_branch = run.branches.create!(
401
+ started_at: started_at,
402
+ status: "in_progress",
403
+ last_advanced_at: started_at,
404
+ pipeline_klass: pipeline_klass
405
+ )
406
+
407
+ sibling_ids.each do |sibling_id|
408
+ Ductwork::BranchLink
409
+ .create!(parent_branch_id: sibling_id, child_branch_id: next_branch.id)
410
+ end
411
+
412
+ next_step = next_branch.steps.create!(
413
+ run: run,
414
+ branch: next_branch,
415
+ node: next_node,
416
+ klass: klass,
417
+ status: "in_progress",
418
+ to_transition: "collapse",
419
+ started_at: started_at
420
+ )
421
+ Ductwork::Job.enqueue(next_step, input_arg)
422
+ end
423
+
424
+ now = Time.current
425
+ advancement.update!(completed_at: now)
426
+ transition.update!(completed_at: now)
427
+ run.resolve_terminal_state!
428
+ end
429
+ end
430
+
431
+ # NOTE: the matching `expand`'s node is recorded on the collapse edge as
432
+ # `barrier_node`. The branch that ran it (the expanding branch, whose direct
433
+ # children coordinate the fan-in) is this collapsing branch's nearest
434
+ # ancestor carrying that node, so we walk parent links up to it. The walk is
435
+ # bounded by definition depth and converges across multi-parent `combine`
436
+ # junctions. Legacy definitions without `barrier_node` fall back to the
437
+ # immediate parent, correct only for a bare `expand`->`collapse`.
438
+ def resolve_fan_in_barrier_branch_id(edge)
439
+ barrier_node = edge[:barrier_node]
440
+ return parent_junctions.pick(:parent_branch_id) if barrier_node.blank?
441
+
442
+ ids = [id]
443
+
444
+ loop do
445
+ match = Ductwork::Step
446
+ .where(run_id: run.id, branch_id: ids, node: Array(barrier_node))
447
+ .pick(:branch_id)
448
+ return match if match
449
+
450
+ ids = Ductwork::BranchLink
451
+ .where(child_branch_id: ids)
452
+ .pluck(:parent_branch_id)
453
+ .uniq
454
+
455
+ if ids.empty?
456
+ raise Ductwork::Branch::TransitionError,
457
+ "could not resolve fan-in barrier branch for collapse " \
458
+ "(barrier_node=#{barrier_node})"
459
+ end
460
+ end
461
+ end
462
+
463
+ def ensure_fan_in_cohort_link!(parent_branch_id)
464
+ return if Ductwork::BranchLink.exists?(
465
+ parent_branch_id: parent_branch_id,
466
+ child_branch_id: id
467
+ )
468
+
469
+ Ductwork::BranchLink
470
+ .create!(parent_branch_id: parent_branch_id, child_branch_id: id)
471
+ end
472
+
473
+ # NOTE: the barrier branch can carry children that are not collapse siblings
474
+ # (its `expand` children, the collapse target), so scope to children that ran
475
+ # the collapse-source `node`. Legacy definitions (no `barrier_node`) keep the
476
+ # original behavior of treating every child as a sibling.
477
+ def collapse_sibling_ids(parent_branch_id, edge, node)
478
+ links = Ductwork::BranchLink.where(parent_branch_id:)
479
+ return links.pluck(:child_branch_id) if edge[:barrier_node].blank?
480
+
481
+ sibling_branch_ids = Ductwork::Step
482
+ .where(run_id: run.id, node: node)
483
+ .select(:branch_id)
484
+ links
485
+ .where(child_branch_id: sibling_branch_ids)
486
+ .pluck(:child_branch_id)
487
+ end
488
+
489
+ # NOTE: the fan-in fires when every sibling has both ARRIVED and completed.
490
+ # The expected count is the barrier branch's `expand` fan-out width — the
491
+ # children that ran the `expand` target node — which is known up front even
492
+ # though the siblings themselves materialize lazily through the intervening
493
+ # transitions. Without the arrival gate a sibling could fire early, before
494
+ # its peers' chains have reached the collapse, minting duplicate targets.
495
+ def fan_in_complete?(parent_branch_id, edge, sibling_ids)
496
+ all_completed = Ductwork::Branch
497
+ .where(id: sibling_ids)
498
+ .where.not(status: :completed)
499
+ .none?
500
+ return all_completed if edge[:barrier_node].blank?
501
+
502
+ expand_target_node = run.parsed_definition.dig(:edges, edge[:barrier_node], :to).sole
503
+ expand_child_ids = Ductwork::Step
504
+ .where(run_id: run.id, node: expand_target_node)
505
+ .select(:branch_id)
506
+ expected = Ductwork::BranchLink
507
+ .where(parent_branch_id:)
508
+ .where(child_branch_id: expand_child_ids)
509
+ .count
510
+
511
+ sibling_ids.length == expected && all_completed
512
+ end
513
+
514
+ def combine_branch(edge, transition, advancement) # rubocop:todo Metrics
515
+ parent_branch_id = parent_junctions.pick(:parent_branch_id)
516
+
517
+ with_claim_fence do # rubocop:todo Metrics/BlockLength
518
+ # NOTE: lock the parent branch rather than the whole pipeline run
519
+ # because at-most we're only coordinating across child branches of the
520
+ # parent branch
521
+ Ductwork::Branch.find(parent_branch_id).lock!
522
+ latest_step.update!(status: :completed, completed_at: Time.current)
523
+ complete!
524
+
525
+ sibling_ids = Ductwork::BranchLink
526
+ .where(parent_branch_id:)
527
+ .pluck(:child_branch_id)
528
+ sibling_branches = Ductwork::Branch.where(id: sibling_ids)
529
+ all_siblings_completed = sibling_branches
530
+ .where.not(status: :completed)
531
+ .none?
532
+
533
+ if all_siblings_completed
534
+ final_step_ids = sibling_branches.map { |b| b.latest_step.id }
535
+ input_arg = Ductwork::Job
536
+ .where(step_id: final_step_ids)
537
+ .map(&:return_value)
538
+ next_node = edge[:to].sole
539
+ klass = run.parsed_definition.dig(:edges, next_node, :klass)
540
+ started_at = Time.current
541
+ next_branch = run.branches.create!(
542
+ started_at: started_at,
543
+ status: "in_progress",
544
+ last_advanced_at: started_at,
545
+ pipeline_klass: pipeline_klass
546
+ )
547
+
548
+ sibling_ids.each do |sibling_id|
549
+ Ductwork::BranchLink
550
+ .create!(parent_branch_id: sibling_id, child_branch_id: next_branch.id)
551
+ end
552
+
553
+ next_step = next_branch.steps.create!(
554
+ run: run,
555
+ branch: next_branch,
556
+ node: next_node,
557
+ klass: klass,
558
+ status: "in_progress",
559
+ to_transition: "combine",
560
+ started_at: started_at
561
+ )
562
+ Ductwork::Job.enqueue(next_step, input_arg)
563
+ end
564
+
565
+ now = Time.current
566
+ advancement.update!(completed_at: now)
567
+ transition.update!(completed_at: now)
568
+ run.resolve_terminal_state!
569
+ end
570
+ end
571
+
572
+ def converge_branch(edge, transition, advancement)
573
+ input_arg = Ductwork::Job.find_by(step: latest_step).return_value
574
+ node = edge[:to].sole
575
+ klass = run.parsed_definition.dig(:edges, node, :klass)
576
+ started_at = Time.current
577
+
578
+ with_claim_fence do
579
+ latest_step.update!(status: :completed, completed_at: Time.current)
580
+ # NOTE: we stay on the same branch for `converge`-ing
581
+ next_step = steps.create!(
582
+ run: run,
583
+ node: node,
584
+ klass: klass,
585
+ status: "in_progress",
586
+ to_transition: "converge",
587
+ started_at: started_at
588
+ )
589
+ Ductwork::Job.enqueue(next_step, input_arg)
590
+
591
+ now = Time.current
592
+ advancement.update!(completed_at: now)
593
+ transition.update!(completed_at: now)
594
+ release!
595
+ end
596
+ end
597
+
598
+ def divert_branch(edge, transition, advancement) # rubocop:disable Metrics/AbcSize
599
+ input_arg = Ductwork::Job.find_by(step: latest_step).return_value
600
+ node = edge[:to][input_arg.to_s] || edge[:to]["otherwise"]
601
+ klass = run.parsed_definition.dig(:edges, node, :klass)
602
+ started_at = Time.current
603
+
604
+ if node.nil?
605
+ with_claim_fence do
606
+ latest_step.update!(status: :completed, completed_at: Time.current)
607
+ halt_branch_and_resolve_run!(transition, advancement, "condition_unmatched")
608
+ end
609
+ else
610
+ with_claim_fence do
611
+ latest_step.update!(status: :completed, completed_at: Time.current)
612
+ next_step = steps.create!(
613
+ run: run,
614
+ node: node,
615
+ klass: klass,
616
+ status: "in_progress",
617
+ to_transition: "divert",
618
+ started_at: started_at
619
+ )
620
+ Ductwork::Job.enqueue(next_step, input_arg)
621
+
622
+ now = Time.current
623
+ advancement.update!(completed_at: now)
624
+ transition.update!(completed_at: now)
625
+ release!
626
+ end
627
+ end
628
+ end
629
+
630
+ def divide_branch(edge, transition, advancement) # rubocop:todo Metrics
631
+ started_at = Time.current
632
+ input_arg = Ductwork::Job.find_by(step: latest_step).return_value
633
+ too_many = edge[:to].tally.any? do |to_klass, count|
634
+ depth = Ductwork
635
+ .configuration
636
+ .steps_max_depth(pipeline: pipeline_klass, step: to_klass)
637
+
638
+ depth != -1 && count > depth
639
+ end
640
+
641
+ if too_many
642
+ with_claim_fence do
643
+ latest_step.update!(status: :completed, completed_at: Time.current)
644
+ halt_branch_and_resolve_run!(transition, advancement, "max_fanout_exceeded")
645
+ end
646
+ else
647
+ with_claim_fence do
648
+ latest_step.update!(status: :completed, completed_at: Time.current)
649
+ complete!
650
+ edge[:to].each do |to|
651
+ klass = run.parsed_definition.dig(:edges, to, :klass)
652
+ branch = run.branches.create!(
653
+ started_at: started_at,
654
+ status: "in_progress",
655
+ last_advanced_at: started_at,
656
+ pipeline_klass: pipeline_klass
657
+ )
658
+
659
+ BranchLink.create!(parent_branch: self, child_branch: branch)
660
+ next_step = branch.steps.create!(
661
+ run: run,
662
+ node: to,
663
+ klass: klass,
664
+ status: "in_progress",
665
+ to_transition: "divide",
666
+ started_at: started_at
667
+ )
668
+ Ductwork::Job.enqueue(next_step, input_arg)
669
+ end
670
+
671
+ now = Time.current
672
+ advancement.update!(completed_at: now)
673
+ transition.update!(completed_at: now)
674
+ end
675
+ end
676
+ end
677
+
678
+ def expand_branch(edge, transition, advancement)
679
+ next_klass = run.parsed_definition.dig(:edges, edge[:to].sole, :klass)
680
+ return_value = Ductwork::Job.find_by(step: latest_step).return_value
681
+ max_depth = Ductwork.configuration.steps_max_depth(
682
+ pipeline: pipeline_klass,
683
+ step: next_klass
684
+ )
685
+
686
+ if max_depth != -1 && return_value.count > max_depth
687
+ with_claim_fence do
688
+ latest_step.update!(status: :completed, completed_at: Time.current)
689
+ halt_branch_and_resolve_run!(transition, advancement, "max_fanout_exceeded")
690
+ end
691
+ elsif return_value.none?
692
+ complete_branch_and_pipeline(transition, advancement)
693
+ else
694
+ bulk_create_steps_and_jobs(edge:, return_value:, transition:, advancement:)
695
+ end
696
+ end
697
+
698
+ def bulk_create_steps_and_jobs(edge:, return_value:, transition:, advancement:) # rubocop:todo Metrics
699
+ node = edge[:to].sole
700
+ next_klass = run.parsed_definition.dig(:edges, node, :klass)
701
+ now = Time.current
702
+
703
+ with_claim_fence do # rubocop:todo Metrics/BlockLength
704
+ latest_step.update!(status: :completed, completed_at: Time.current)
705
+ complete!
706
+
707
+ Array(return_value).each_slice(1_000).each do |batch| # rubocop:todo Metrics/BlockLength
708
+ branch_rows = []
709
+ branch_junction_rows = []
710
+ step_rows = []
711
+ job_rows = []
712
+ execution_rows = []
713
+ availability_rows = []
714
+
715
+ batch.each do |value| # rubocop:todo Metrics/BlockLength
716
+ branch_id = SecureRandom.uuid_v7
717
+ branch_junction_id = SecureRandom.uuid_v7
718
+ step_id = SecureRandom.uuid_v7
719
+ job_id = SecureRandom.uuid_v7
720
+ execution_id = SecureRandom.uuid_v7
721
+ availability_id = SecureRandom.uuid_v7
722
+
723
+ branch_rows << {
724
+ id: branch_id,
725
+ run_id: run.id,
726
+ pipeline_klass: pipeline_klass,
727
+ status: "in_progress",
728
+ started_at: now,
729
+ last_advanced_at: now,
730
+ created_at: now,
731
+ updated_at: now,
732
+ }
733
+ branch_junction_rows << {
734
+ id: branch_junction_id,
735
+ parent_branch_id: id,
736
+ child_branch_id: branch_id,
737
+ created_at: now,
738
+ updated_at: now,
739
+ }
740
+ step_rows << {
741
+ id: step_id,
742
+ run_id: run.id,
743
+ branch_id: branch_id,
744
+ node: node,
745
+ klass: next_klass,
746
+ status: "in_progress",
747
+ to_transition: "expand",
748
+ started_at: now,
749
+ created_at: now,
750
+ updated_at: now,
751
+ }
752
+ job_rows << {
753
+ id: job_id,
754
+ step_id: step_id,
755
+ input_args: JSON.dump({ args: [value] }),
756
+ klass: next_klass,
757
+ started_at: now,
758
+ created_at: now,
759
+ updated_at: now,
760
+ }
761
+ execution_rows << {
762
+ id: execution_id,
763
+ job_id: job_id,
764
+ retry_count: 0,
765
+ crash_count: 0,
766
+ started_at: now,
767
+ created_at: now,
768
+ updated_at: now,
769
+ }
770
+ availability_rows << {
771
+ id: availability_id,
772
+ execution_id: execution_id,
773
+ pipeline_klass: pipeline_klass,
774
+ started_at: now,
775
+ created_at: now,
776
+ updated_at: now,
777
+ }
778
+ end
779
+
780
+ Ductwork::Branch.insert_all!(branch_rows)
781
+ Ductwork::BranchLink.insert_all!(branch_junction_rows)
782
+ Ductwork::Step.insert_all!(step_rows)
783
+ Ductwork::Job.insert_all!(job_rows)
784
+ Ductwork::Execution.insert_all!(execution_rows)
785
+ Ductwork::Availability.insert_all!(availability_rows)
786
+
787
+ Ductwork.logger.info(
788
+ msg: "Job batch enqueued",
789
+ count: batch.count,
790
+ job_klass: next_klass
791
+ )
792
+ end
793
+
794
+ now = Time.current
795
+ advancement.update!(completed_at: now)
796
+ transition.update!(completed_at: now)
797
+ end
798
+ end
799
+ end
800
+ end