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.
- 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 +60 -0
- data/CHANGELOG.md +144 -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 +6 -1
- data/lib/ductwork/dsl/definition_builder.rb +24 -1
- 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 +150 -272
- 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
|
@@ -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
|