ductwork 0.25.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/audit-clock-drift/SKILL.md +11 -0
- data/.claude/skills/audit-database-indexes/SKILL.md +11 -0
- data/.claude/skills/audit-database-support/SKILL.md +18 -0
- data/.claude/skills/audit-durability/SKILL.md +17 -0
- data/.saturnci/database.yml +1 -0
- data/CHANGELOG-PRO.md +72 -0
- data/CHANGELOG.md +148 -1
- data/CLAUDE.md +34 -0
- data/README.md +24 -14
- data/app/controllers/ductwork/application_controller.rb +3 -3
- data/app/controllers/ductwork/dashboards_controller.rb +1 -1
- data/app/controllers/ductwork/pipelines_controller.rb +4 -3
- data/app/helpers/ductwork/application_helper.rb +2 -2
- data/app/views/ductwork/dashboards/show.html.erb +12 -12
- data/app/views/ductwork/pipelines/index.html.erb +12 -12
- data/app/views/ductwork/pipelines/show.html.erb +13 -13
- data/app/views/ductwork/step_errors/index.html.erb +2 -2
- data/lib/ductwork/branch_claim.rb +185 -0
- data/lib/ductwork/cli.rb +73 -24
- data/lib/ductwork/configuration.rb +73 -3
- data/lib/ductwork/context.rb +17 -19
- data/lib/ductwork/database_clock.rb +84 -0
- data/lib/ductwork/dsl/branch_builder.rb +46 -1
- data/lib/ductwork/dsl/definition_builder.rb +101 -2
- data/lib/ductwork/{job_claim.rb → execution_claim.rb} +7 -6
- data/lib/ductwork/fault_injection.rb +35 -0
- data/lib/ductwork/migration_helper.rb +1 -1
- data/lib/ductwork/models/advancement.rb +48 -0
- data/lib/ductwork/models/attempt.rb +9 -0
- data/lib/ductwork/models/availability.rb +2 -0
- data/lib/ductwork/models/branch.rb +800 -0
- data/lib/ductwork/models/branch_link.rb +10 -0
- data/lib/ductwork/models/execution.rb +199 -2
- data/lib/ductwork/models/job.rb +8 -121
- data/lib/ductwork/models/pipeline.rb +152 -255
- data/lib/ductwork/models/process.rb +177 -5
- data/lib/ductwork/models/result.rb +2 -1
- data/lib/ductwork/models/run.rb +119 -1
- data/lib/ductwork/models/step.rb +26 -6
- data/lib/ductwork/models/transition.rb +15 -0
- data/lib/ductwork/models/tuple.rb +1 -4
- data/lib/ductwork/models/workflow.rb +3 -0
- data/lib/ductwork/optimistic_locking_execution_claim.rb +97 -0
- data/lib/ductwork/processes/health_check.rb +87 -0
- data/lib/ductwork/processes/job_worker.rb +86 -20
- data/lib/ductwork/processes/job_worker_runner.rb +25 -37
- data/lib/ductwork/processes/pipeline_advancer.rb +48 -76
- data/lib/ductwork/processes/pipeline_advancer_runner.rb +34 -44
- data/lib/ductwork/processes/process_supervisor.rb +87 -10
- data/lib/ductwork/processes/thread_supervisor.rb +55 -21
- data/lib/ductwork/processes/thread_supervisor_runner.rb +8 -5
- data/lib/ductwork/processes/worker_health_check.rb +55 -0
- data/lib/ductwork/row_locking_execution_claim.rb +77 -0
- data/lib/ductwork/testing/rspec.rb +28 -11
- data/lib/ductwork/version.rb +1 -1
- data/lib/ductwork.rb +5 -0
- data/lib/generators/ductwork/install/install_generator.rb +14 -4
- data/lib/generators/ductwork/install/templates/config/ductwork.yml +4 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_advancements.rb +29 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_attempts.rb +20 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +18 -5
- data/lib/generators/ductwork/install/templates/db/create_ductwork_branch_links.rb +27 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_branches.rb +37 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +8 -1
- data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +0 -8
- data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +2 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +1 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +45 -6
- data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +20 -5
- data/lib/generators/ductwork/install/templates/db/create_ductwork_transitions.rb +43 -0
- data/lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb +3 -3
- data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_advancements.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_executions.rb +14 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_results.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_runs.rb +9 -0
- data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_transitions.rb +16 -0
- data/lib/generators/ductwork/update/templates/db/add_pipeline_started_index_to_ductwork_runs.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/add_process_id_to_ductwork_executions.rb +35 -0
- data/lib/generators/ductwork/update/templates/db/add_role_to_ductwork_processes.rb +20 -0
- data/lib/generators/ductwork/update/templates/db/associate_branches_to_runs.rb +74 -0
- data/lib/generators/ductwork/update/templates/db/associate_steps_to_branches.rb +24 -0
- data/lib/generators/ductwork/update/templates/db/associate_steps_to_runs.rb +72 -0
- data/lib/generators/ductwork/update/templates/db/associate_tuples_to_runs.rb +73 -0
- data/lib/generators/ductwork/update/templates/db/backfill_branch_ids_on_steps.rb +21 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_advancements.rb +28 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_branch_links.rb +27 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_branches.rb +37 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_runs.rb +55 -0
- data/lib/generators/ductwork/update/templates/db/create_ductwork_transitions.rb +32 -0
- data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb +11 -4
- data/lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb +5 -0
- data/lib/generators/ductwork/update/templates/db/rename_runs_to_attempts.rb +7 -0
- data/lib/generators/ductwork/update/templates/db/update_process_associations.rb +29 -0
- data/lib/generators/ductwork/update/update_generator.rb +99 -0
- metadata +47 -6
- data/lib/ductwork/optimistic_locking_job_claim.rb +0 -88
- data/lib/ductwork/row_locking_job_claim.rb +0 -75
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
</h1>
|
|
6
6
|
|
|
7
7
|
<h2>
|
|
8
|
-
<span class="status-pill big-pill <%= @
|
|
9
|
-
<%= @
|
|
8
|
+
<span class="status-pill big-pill <%= @last_run.status %>">
|
|
9
|
+
<%= @last_run.status.gsub("_", "-") %>
|
|
10
10
|
</span>
|
|
11
11
|
</h2>
|
|
12
12
|
</div>
|
|
@@ -30,34 +30,34 @@
|
|
|
30
30
|
<div>
|
|
31
31
|
<label>Runtime</label>
|
|
32
32
|
<span>
|
|
33
|
-
<% if @
|
|
34
|
-
<div data-started-at=<%= @
|
|
33
|
+
<% if @last_run.completed_at.nil? %>
|
|
34
|
+
<div data-started-at=<%= @last_run.started_at.iso8601 %>>
|
|
35
35
|
<span id="elapsed-timer">0h 0m 0s</span>
|
|
36
36
|
</div>
|
|
37
37
|
<% else %>
|
|
38
|
-
<%= formatted_time_distance(@
|
|
38
|
+
<%= formatted_time_distance(@last_run.started_at, @last_run.completed_at) %>
|
|
39
39
|
<% end %>
|
|
40
40
|
</span>
|
|
41
41
|
</div>
|
|
42
42
|
<div class="metric-heading">
|
|
43
43
|
<label>Triggered At</label>
|
|
44
44
|
<span>
|
|
45
|
-
<%= @
|
|
45
|
+
<%= @last_run.triggered_at.iso8601(3) %>
|
|
46
46
|
</span>
|
|
47
47
|
</div>
|
|
48
48
|
</div>
|
|
49
49
|
|
|
50
50
|
<div>
|
|
51
51
|
<div class="metric-heading">
|
|
52
|
-
<label>
|
|
52
|
+
<label>Started At</label>
|
|
53
53
|
<span>
|
|
54
|
-
<%= @
|
|
54
|
+
<%= @last_run.started_at.iso8601(3) %>
|
|
55
55
|
</span>
|
|
56
56
|
</div>
|
|
57
57
|
<div>
|
|
58
58
|
<label>Completed At</label>
|
|
59
59
|
<span>
|
|
60
|
-
<%= @
|
|
60
|
+
<%= @last_run.completed_at&.iso8601(3) %>
|
|
61
61
|
</span>
|
|
62
62
|
</div>
|
|
63
63
|
</div>
|
|
@@ -66,12 +66,12 @@
|
|
|
66
66
|
<div style="margin-top: 1rem;">
|
|
67
67
|
<label>Definition</label>
|
|
68
68
|
<code style="width: 100%;">
|
|
69
|
-
<%= @
|
|
69
|
+
<%= @last_run.parsed_definition %>
|
|
70
70
|
</code>
|
|
71
71
|
</div>
|
|
72
72
|
</article>
|
|
73
73
|
|
|
74
|
-
<h3>Steps (<%= @
|
|
74
|
+
<h3>Steps (<%= @last_run.steps.count %>)</h3>
|
|
75
75
|
|
|
76
76
|
<div class="separate-content">
|
|
77
77
|
<div class="filter-group">
|
|
@@ -245,7 +245,7 @@
|
|
|
245
245
|
<td><%= execution.retry_count + 1 %></td>
|
|
246
246
|
<td><%= execution.started_at.strftime("%H:%M:%S.%3N") %></td>
|
|
247
247
|
<td><%= execution.availability.completed_at&.strftime("%H:%M:%S.%3N") %></td>
|
|
248
|
-
<td><%= execution.
|
|
248
|
+
<td><%= execution.attempt&.started_at&.strftime("%H:%M:%S.%3N") %></td>
|
|
249
249
|
<td><%= execution.completed_at&.strftime("%H:%M:%S.%3N") %></td>
|
|
250
250
|
<td>
|
|
251
251
|
<% if execution.result.present? && execution.result.failure? %>
|
|
@@ -269,7 +269,7 @@
|
|
|
269
269
|
<%= execution.result.error_message %>
|
|
270
270
|
</h5>
|
|
271
271
|
<code>
|
|
272
|
-
<%= execution.result.error_backtrace
|
|
272
|
+
<%= (execution.result.error_backtrace&.split("\n") || []).each do |line| %>
|
|
273
273
|
<div><%= line %></div>
|
|
274
274
|
<% end %>
|
|
275
275
|
</code>
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
</td>
|
|
57
57
|
<td>
|
|
58
58
|
<code>
|
|
59
|
-
<%= link_to(result.execution.job.step.pipeline_id, pipeline_path(result.execution.job.step.pipeline_id)) %>
|
|
59
|
+
<%= link_to(result.execution.job.step.run.pipeline_id, pipeline_path(result.execution.job.step.run.pipeline_id)) %>
|
|
60
60
|
</code>
|
|
61
61
|
</td>
|
|
62
62
|
<td>
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
<%= result.error_message %>
|
|
86
86
|
</h5>
|
|
87
87
|
<code>
|
|
88
|
-
<%= result.error_backtrace
|
|
88
|
+
<%= (result.error_backtrace&.split("\n") || []).each do |line| %>
|
|
89
89
|
<div><%= line %></div>
|
|
90
90
|
<% end %>
|
|
91
91
|
</code>
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
class BranchClaim
|
|
5
|
+
attr_reader :transition, :advancement, :token
|
|
6
|
+
|
|
7
|
+
def initialize(pipeline_klass)
|
|
8
|
+
@pipeline_klass = pipeline_klass
|
|
9
|
+
@claimed_for_advancing_at = nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def latest
|
|
13
|
+
# NOTE: an advancement must attach to a live process record so the reaper
|
|
14
|
+
# can find and recover it if this process dies. `Process.current` is only
|
|
15
|
+
# nil when our record was reaped for a stale heartbeat (e.g. host suspend)
|
|
16
|
+
# and the runner has not re-adopted it yet — i.e. the system has already
|
|
17
|
+
# declared this process dead. Claiming as a presumed-dead zombie would
|
|
18
|
+
# create an advancement with a nil `process_id` that no reaper sweep can
|
|
19
|
+
# reach, orphaning the branch. Skip this cycle instead; the runner
|
|
20
|
+
# re-adopts within its heartbeat interval.
|
|
21
|
+
process = Ductwork::Process.current
|
|
22
|
+
|
|
23
|
+
return log_no_process if process.nil?
|
|
24
|
+
|
|
25
|
+
id = find_candidate_branch_id
|
|
26
|
+
|
|
27
|
+
return log_no_branches if id.blank?
|
|
28
|
+
|
|
29
|
+
rows_updated = claim_and_setup_records(id, process)
|
|
30
|
+
|
|
31
|
+
if rows_updated == 1
|
|
32
|
+
Ductwork::Branch.find(id)
|
|
33
|
+
else
|
|
34
|
+
log_race_condition(id)
|
|
35
|
+
end
|
|
36
|
+
rescue ActiveRecord::InvalidForeignKey => e
|
|
37
|
+
# NOTE: our own `process` record was reaped (heartbeat-stale, destroyed)
|
|
38
|
+
# between the nil-check above and `transition.advancements.create!`
|
|
39
|
+
# inside `claim_and_setup_records`. The insert's FK check blocks on the
|
|
40
|
+
# reaper's row lock and then fails once the parent is gone, rolling back
|
|
41
|
+
# the whole claim transaction (branch + transition + advancement) --
|
|
42
|
+
# nothing is left half-committed. Treat it like any other lost claim
|
|
43
|
+
# race instead of letting it propagate and kill the advancer thread.
|
|
44
|
+
log_process_reaped_mid_claim(id, e)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :pipeline_klass, :claimed_for_advancing_at
|
|
50
|
+
|
|
51
|
+
def find_candidate_branch_id
|
|
52
|
+
Ductwork::Branch
|
|
53
|
+
.in_progress
|
|
54
|
+
.where(pipeline_klass:, claimed_for_advancing_at:)
|
|
55
|
+
.where(steps: Ductwork::Step.where(status: %w[advancing failed]))
|
|
56
|
+
.order(:last_advanced_at)
|
|
57
|
+
.limit(1)
|
|
58
|
+
.pluck(:id)
|
|
59
|
+
.first
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def claim_and_setup_records(id, process)
|
|
63
|
+
now = Time.current
|
|
64
|
+
@token = SecureRandom.uuid
|
|
65
|
+
|
|
66
|
+
Ductwork::Record.transaction do
|
|
67
|
+
rows_updated = Ductwork::Branch
|
|
68
|
+
.where(id:, claimed_for_advancing_at:)
|
|
69
|
+
.update_all(
|
|
70
|
+
claimed_for_advancing_at: now,
|
|
71
|
+
claim_token: token,
|
|
72
|
+
status: :advancing
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if rows_updated == 1
|
|
76
|
+
branch = Branch.find(id)
|
|
77
|
+
@transition = find_or_create_transition(branch, now)
|
|
78
|
+
Ductwork::FaultInjection.checkpoint(:before_advancement_create)
|
|
79
|
+
@advancement = transition.advancements.create!(
|
|
80
|
+
process: process,
|
|
81
|
+
started_at: now,
|
|
82
|
+
crash_count: next_crash_count(transition)
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
rows_updated
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def find_or_create_transition(branch, now)
|
|
91
|
+
existing = branch
|
|
92
|
+
.transitions
|
|
93
|
+
.where(completed_at: nil)
|
|
94
|
+
.order(started_at: :desc)
|
|
95
|
+
.limit(1)
|
|
96
|
+
.first
|
|
97
|
+
|
|
98
|
+
if existing
|
|
99
|
+
fail_abandoned_advancement(existing, now)
|
|
100
|
+
existing
|
|
101
|
+
else
|
|
102
|
+
branch.transitions.create!(
|
|
103
|
+
in_step: branch.latest_step,
|
|
104
|
+
started_at: now
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# NOTE: each crash spawns a fresh advancement at re-claim, so the running
|
|
110
|
+
# crash total lives on the advancement and carries forward (mirroring
|
|
111
|
+
# `Ductwork::Execution#crash_count`). The prior advancement is read after
|
|
112
|
+
# `find_or_create_transition` has already marked any abandoned in-flight
|
|
113
|
+
# advancement as a crash, so both the reaper/thread-cleanup path and the
|
|
114
|
+
# `fail_abandoned_advancement` path are reflected here. A fresh transition
|
|
115
|
+
# starts at 0; a non-crash errored prior carries the total unchanged.
|
|
116
|
+
def next_crash_count(transition)
|
|
117
|
+
prior = transition.advancements.order(started_at: :desc).first
|
|
118
|
+
base = prior&.crash_count || 0
|
|
119
|
+
|
|
120
|
+
if prior&.crash?
|
|
121
|
+
base + 1
|
|
122
|
+
else
|
|
123
|
+
base
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fail_abandoned_advancement(transition, now)
|
|
128
|
+
transition
|
|
129
|
+
.advancements
|
|
130
|
+
.where(completed_at: nil)
|
|
131
|
+
.order(started_at: :desc)
|
|
132
|
+
.limit(1)
|
|
133
|
+
.first
|
|
134
|
+
&.update!(
|
|
135
|
+
completed_at: now,
|
|
136
|
+
error_klass: "Ductwork::ProcessCrash",
|
|
137
|
+
error_message: "Advancement was abandoned from a process crash"
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def log_no_process
|
|
142
|
+
Ductwork.logger.debug(
|
|
143
|
+
msg: "No live process record, skipping branch claim",
|
|
144
|
+
pipeline: pipeline_klass,
|
|
145
|
+
role: :pipeline_advancer
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def log_no_branches
|
|
152
|
+
Ductwork.logger.debug(
|
|
153
|
+
msg: "No branches needs advancing",
|
|
154
|
+
pipeline: pipeline_klass,
|
|
155
|
+
role: :pipeline_advancer
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def log_race_condition(id)
|
|
162
|
+
Ductwork.logger.debug(
|
|
163
|
+
msg: "Did not claim branch, avoided race condition",
|
|
164
|
+
branch_id: id,
|
|
165
|
+
pipeline_klass: pipeline_klass,
|
|
166
|
+
role: :pipeline_advancer
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def log_process_reaped_mid_claim(id, error)
|
|
173
|
+
Ductwork.logger.warn(
|
|
174
|
+
msg: "Did not claim branch, our process record was reaped mid-claim",
|
|
175
|
+
branch_id: id,
|
|
176
|
+
pipeline_klass: pipeline_klass,
|
|
177
|
+
error_klass: error.class.to_s,
|
|
178
|
+
error_message: error.message,
|
|
179
|
+
role: :pipeline_advancer
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
data/lib/ductwork/cli.rb
CHANGED
|
@@ -4,49 +4,60 @@ require "optparse"
|
|
|
4
4
|
|
|
5
5
|
module Ductwork
|
|
6
6
|
class CLI
|
|
7
|
+
DEFAULT_COMMAND = "start"
|
|
8
|
+
|
|
7
9
|
def self.start!(args)
|
|
8
10
|
new(args).start!
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def initialize(args)
|
|
12
|
-
@
|
|
13
|
-
@
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def start!
|
|
17
|
-
option_parser.parse!(args)
|
|
18
|
-
auto_configure
|
|
19
|
-
puts banner
|
|
20
|
-
launch_processes
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
attr_reader :args, :options
|
|
26
|
-
|
|
27
|
-
def option_parser
|
|
28
|
-
OptionParser.new do |op|
|
|
14
|
+
@raw_args = args.dup
|
|
15
|
+
@configuration_options = {}
|
|
16
|
+
@health_check_options = {}
|
|
17
|
+
@top_level_parser = OptionParser.new do |op|
|
|
29
18
|
op.banner = "ductwork [options]"
|
|
30
19
|
|
|
31
20
|
op.on("-c", "--config PATH", "path to YAML config file") do |arg|
|
|
32
|
-
|
|
21
|
+
configuration_options[:path] = arg
|
|
33
22
|
end
|
|
34
23
|
|
|
35
24
|
op.on("-h", "--help", "Prints this help") do
|
|
36
25
|
puts op
|
|
26
|
+
puts "\nCommands:\n start\n health"
|
|
37
27
|
exit
|
|
38
28
|
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
39
31
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
32
|
+
def start!
|
|
33
|
+
parse!
|
|
34
|
+
auto_configure
|
|
35
|
+
execute_command
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :raw_args, :configuration_options, :health_check_options,
|
|
41
|
+
:top_level_parser, :command
|
|
42
|
+
|
|
43
|
+
def parse!
|
|
44
|
+
top_level_parser.order(raw_args)
|
|
45
|
+
@command = raw_args.shift || DEFAULT_COMMAND
|
|
46
|
+
|
|
47
|
+
if command == "start"
|
|
48
|
+
start_command_parser.parse!(raw_args)
|
|
49
|
+
elsif command == "health"
|
|
50
|
+
health_command_parser.parse!(raw_args)
|
|
51
|
+
else
|
|
52
|
+
warn "Unknown command: #{command}"
|
|
53
|
+
puts top_level_parser
|
|
54
|
+
exit 1
|
|
44
55
|
end
|
|
45
56
|
end
|
|
46
57
|
|
|
47
58
|
def auto_configure
|
|
48
|
-
|
|
49
|
-
Ductwork.configuration = Configuration.new(**
|
|
59
|
+
configuration_options[:role] = ENV.fetch("DUCTWORK_ROLE", nil)
|
|
60
|
+
Ductwork.configuration = Configuration.new(**configuration_options)
|
|
50
61
|
Ductwork.logger = if Ductwork.configuration.logger_source == "rails"
|
|
51
62
|
Rails.logger
|
|
52
63
|
else
|
|
@@ -73,8 +84,46 @@ module Ductwork
|
|
|
73
84
|
BANNER
|
|
74
85
|
end
|
|
75
86
|
|
|
87
|
+
def execute_command
|
|
88
|
+
if command == "start"
|
|
89
|
+
launch_processes
|
|
90
|
+
elsif command == "health"
|
|
91
|
+
check_health
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
76
95
|
def launch_processes
|
|
96
|
+
puts banner
|
|
97
|
+
|
|
77
98
|
Ductwork::Processes::Launcher.start_processes!
|
|
78
99
|
end
|
|
100
|
+
|
|
101
|
+
def check_health
|
|
102
|
+
Ductwork::Processes::HealthCheck.run(**health_check_options)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def start_command_parser
|
|
106
|
+
OptionParser.new do |op|
|
|
107
|
+
op.banner = "ductwork start [options]"
|
|
108
|
+
|
|
109
|
+
op.on("-c", "--config PATH") do |arg|
|
|
110
|
+
configuration_options[:path] = arg
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def health_command_parser
|
|
116
|
+
OptionParser.new do |op|
|
|
117
|
+
op.banner = "ductwork health [options]"
|
|
118
|
+
|
|
119
|
+
op.on("-p", "--pid PID") do |arg|
|
|
120
|
+
health_check_options[:pid] = arg.to_i
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
op.on("-V", "--verbose", "Prints all the health check data") do
|
|
124
|
+
health_check_options[:verbose] = true
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
79
128
|
end
|
|
80
129
|
end
|
|
@@ -6,18 +6,24 @@ module Ductwork
|
|
|
6
6
|
DEFAULT_FILE_PATH = "config/ductwork.yml"
|
|
7
7
|
DEFAULT_FORKING = "default" # fork pipeline advancer and job workers
|
|
8
8
|
DEFAULT_JOB_WORKER_COUNT = 5 # threads
|
|
9
|
+
DEFAULT_JOB_WORKER_MAX_CRASH = 10 # attempts
|
|
9
10
|
DEFAULT_JOB_WORKER_MAX_RETRY = 3 # attempts
|
|
10
11
|
DEFAULT_JOB_WORKER_POLLING_TIMEOUT = 1 # second
|
|
11
12
|
DEFAULT_JOB_WORKER_SHUTDOWN_TIMEOUT = 20 # seconds
|
|
12
13
|
DEFAULT_LOGGER_LEVEL = ::Logger::INFO
|
|
13
14
|
DEFAULT_LOGGER_SOURCE = "default" # `Logger` instance writing to STDOUT
|
|
15
|
+
DEFAULT_PIPELINE_ADVANCER_MAX_CRASH = 10 # attempts
|
|
16
|
+
DEFAULT_PIPELINE_ADVANCER_MAX_RETRY = 10 # attempts
|
|
17
|
+
DEFAULT_PIPELINE_ADVANCER_THREAD_COUNT = 3 # threads
|
|
14
18
|
DEFAULT_PIPELINE_POLLING_TIMEOUT = 1 # second
|
|
15
19
|
DEFAULT_PIPELINE_SHUTDOWN_TIMEOUT = 20 # seconds
|
|
16
20
|
DEFAULT_ROLE = "all" # supervisor, pipeline advancer, and job workers
|
|
17
21
|
DEFAULT_STEPS_MAX_DEPTH = -1 # unlimited count
|
|
18
22
|
DEFAULT_SUPERVISOR_POLLING_TIMEOUT = 1 # second
|
|
23
|
+
DEFAULT_SUPERVISOR_REAPER_TIMEOUT = 300 # seconds
|
|
19
24
|
DEFAULT_SUPERVISOR_SHUTDOWN_TIMEOUT = 30 # seconds
|
|
20
25
|
DEFAULT_LOGGER = ::Logger.new($stdout)
|
|
26
|
+
PIPELINES_DIRECTORIES = %w[app/pipelines app/workflows].freeze
|
|
21
27
|
PIPELINES_WILDCARD = "*"
|
|
22
28
|
VALID_ROLES = %w[all advancer worker].freeze
|
|
23
29
|
|
|
@@ -25,10 +31,14 @@ module Ductwork
|
|
|
25
31
|
|
|
26
32
|
attr_writer :job_worker_count, :job_worker_polling_timeout,
|
|
27
33
|
:job_worker_shutdown_timeout, :job_worker_max_retry,
|
|
34
|
+
:job_worker_max_crash,
|
|
28
35
|
:logger_level,
|
|
36
|
+
:pipeline_advancer_count, :pipeline_advancer_max_crash,
|
|
37
|
+
:pipeline_advancer_max_retry,
|
|
29
38
|
:pipeline_polling_timeout, :pipeline_shutdown_timeout,
|
|
30
39
|
:steps_max_depth,
|
|
31
|
-
:supervisor_polling_timeout, :
|
|
40
|
+
:supervisor_polling_timeout, :supervisor_reaper_timeout,
|
|
41
|
+
:supervisor_shutdown_timeout
|
|
32
42
|
|
|
33
43
|
def initialize(path: DEFAULT_FILE_PATH, role: nil)
|
|
34
44
|
full_path = Pathname.new(path)
|
|
@@ -57,9 +67,10 @@ module Ductwork
|
|
|
57
67
|
raw_pipelines = config[:pipelines] || []
|
|
58
68
|
|
|
59
69
|
if raw_pipelines == PIPELINES_WILDCARD
|
|
60
|
-
|
|
61
|
-
.glob("**/*.rb", base:
|
|
70
|
+
PIPELINES_DIRECTORIES
|
|
71
|
+
.flat_map { |dir| Dir.glob("**/*.rb", base: dir) }
|
|
62
72
|
.map { |path| path.delete_suffix(".rb").camelize }
|
|
73
|
+
.uniq
|
|
63
74
|
else
|
|
64
75
|
raw_pipelines.map(&:strip)
|
|
65
76
|
end
|
|
@@ -81,6 +92,24 @@ module Ductwork
|
|
|
81
92
|
end
|
|
82
93
|
end
|
|
83
94
|
|
|
95
|
+
def job_worker_max_crash(pipeline: nil, step: nil) # rubocop:disable Metrics
|
|
96
|
+
return @job_worker_max_crash if instance_variable_defined?(:@job_worker_max_crash)
|
|
97
|
+
|
|
98
|
+
pipeline ||= :default
|
|
99
|
+
step ||= :default
|
|
100
|
+
base_config = config.dig(:job_worker, :max_crash)
|
|
101
|
+
|
|
102
|
+
if base_config.is_a?(Hash) && base_config[pipeline.to_sym].is_a?(Hash)
|
|
103
|
+
pipeline_config = config.dig(:job_worker, :max_crash, pipeline.to_sym)
|
|
104
|
+
|
|
105
|
+
pipeline_config[step.to_sym] || pipeline_config[:default] || DEFAULT_JOB_WORKER_MAX_CRASH
|
|
106
|
+
elsif base_config.is_a?(Hash)
|
|
107
|
+
base_config[pipeline.to_sym] || base_config[:default] || DEFAULT_JOB_WORKER_MAX_CRASH
|
|
108
|
+
else
|
|
109
|
+
base_config || DEFAULT_JOB_WORKER_MAX_CRASH
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
84
113
|
def job_worker_max_retry(pipeline: nil, step: nil) # rubocop:disable Metrics
|
|
85
114
|
return @job_worker_max_retry if instance_variable_defined?(:@job_worker_max_retry)
|
|
86
115
|
|
|
@@ -125,6 +154,28 @@ module Ductwork
|
|
|
125
154
|
@logger_source ||= fetch_logger_source
|
|
126
155
|
end
|
|
127
156
|
|
|
157
|
+
def pipeline_advancer_count(pipeline = nil)
|
|
158
|
+
pipeline ||= :default
|
|
159
|
+
default = DEFAULT_PIPELINE_ADVANCER_THREAD_COUNT
|
|
160
|
+
base_config = config.dig(:pipeline_advancer, :count)
|
|
161
|
+
|
|
162
|
+
if instance_variable_defined?(:@pipeline_advancer_count)
|
|
163
|
+
@pipeline_advancer_count
|
|
164
|
+
elsif base_config.is_a?(Hash)
|
|
165
|
+
base_config[pipeline.to_sym] || base_config[:default] || default
|
|
166
|
+
else
|
|
167
|
+
base_config || default
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def pipeline_advancer_max_crash
|
|
172
|
+
@pipeline_advancer_max_crash ||= fetch_pipeline_advancer_max_crash
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def pipeline_advancer_max_retry
|
|
176
|
+
@pipeline_advancer_max_retry ||= fetch_pipeline_advancer_max_retry
|
|
177
|
+
end
|
|
178
|
+
|
|
128
179
|
def pipeline_polling_timeout(pipeline = nil)
|
|
129
180
|
pipeline ||= :default
|
|
130
181
|
default = DEFAULT_PIPELINE_POLLING_TIMEOUT
|
|
@@ -169,6 +220,10 @@ module Ductwork
|
|
|
169
220
|
@supervisor_polling_timeout ||= fetch_supervisor_polling_timeout
|
|
170
221
|
end
|
|
171
222
|
|
|
223
|
+
def supervisor_reaper_timeout
|
|
224
|
+
@supervisor_reaper_timeout ||= fetch_supervisor_reaper_timeout
|
|
225
|
+
end
|
|
226
|
+
|
|
172
227
|
def supervisor_shutdown_timeout
|
|
173
228
|
@supervisor_shutdown_timeout ||= fetch_supervisor_shutdown_timeout
|
|
174
229
|
end
|
|
@@ -190,6 +245,16 @@ module Ductwork
|
|
|
190
245
|
config.dig(:logger, :source) || DEFAULT_LOGGER_SOURCE
|
|
191
246
|
end
|
|
192
247
|
|
|
248
|
+
def fetch_pipeline_advancer_max_crash
|
|
249
|
+
config.dig(:pipeline_advancer, :max_crash) ||
|
|
250
|
+
DEFAULT_PIPELINE_ADVANCER_MAX_CRASH
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def fetch_pipeline_advancer_max_retry
|
|
254
|
+
config.dig(:pipeline_advancer, :max_retry) ||
|
|
255
|
+
DEFAULT_PIPELINE_ADVANCER_MAX_RETRY
|
|
256
|
+
end
|
|
257
|
+
|
|
193
258
|
def fetch_pipeline_shutdown_timeout
|
|
194
259
|
config.dig(:pipeline_advancer, :shutdown_timeout) ||
|
|
195
260
|
DEFAULT_PIPELINE_SHUTDOWN_TIMEOUT
|
|
@@ -200,6 +265,11 @@ module Ductwork
|
|
|
200
265
|
DEFAULT_SUPERVISOR_POLLING_TIMEOUT
|
|
201
266
|
end
|
|
202
267
|
|
|
268
|
+
def fetch_supervisor_reaper_timeout
|
|
269
|
+
config.dig(:supervisor, :reaper_timeout) ||
|
|
270
|
+
DEFAULT_SUPERVISOR_REAPER_TIMEOUT
|
|
271
|
+
end
|
|
272
|
+
|
|
203
273
|
def fetch_supervisor_shutdown_timeout
|
|
204
274
|
config.dig(:supervisor, :shutdown_timeout) ||
|
|
205
275
|
DEFAULT_SUPERVISOR_SHUTDOWN_TIMEOUT
|
data/lib/ductwork/context.rb
CHANGED
|
@@ -4,42 +4,40 @@ module Ductwork
|
|
|
4
4
|
class Context
|
|
5
5
|
class OverwriteError < StandardError; end
|
|
6
6
|
|
|
7
|
-
def initialize(
|
|
8
|
-
@
|
|
7
|
+
def initialize(run_id)
|
|
8
|
+
@run_id = run_id
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def get(key)
|
|
12
12
|
raise ArgumentError, "Key must be a string" if !key.is_a?(String)
|
|
13
13
|
|
|
14
|
-
Ductwork
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
&.value
|
|
19
|
-
end
|
|
14
|
+
Ductwork::Tuple
|
|
15
|
+
.select(:serialized_value)
|
|
16
|
+
.find_by(run_id:, key:)
|
|
17
|
+
&.value
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def set(key, value, overwrite: false)
|
|
23
21
|
attributes = {
|
|
24
22
|
id: SecureRandom.uuid_v7,
|
|
25
|
-
|
|
23
|
+
run_id: run_id,
|
|
26
24
|
key: key,
|
|
27
25
|
serialized_value: Ductwork::Tuple.serialize(value),
|
|
28
26
|
first_set_at: Time.current,
|
|
29
27
|
last_set_at: Time.current,
|
|
30
28
|
}
|
|
31
|
-
|
|
29
|
+
opts = if Ductwork::Tuple.connection.adapter_name.match?(/mysql|trilogy/i)
|
|
30
|
+
{}
|
|
31
|
+
else
|
|
32
|
+
{ unique_by: %i[run_id key] }
|
|
33
|
+
end
|
|
32
34
|
|
|
33
35
|
if overwrite
|
|
34
|
-
Ductwork.
|
|
35
|
-
Ductwork::Tuple.upsert(attributes, unique_by:)
|
|
36
|
-
end
|
|
36
|
+
Ductwork::Tuple.upsert(attributes, **opts)
|
|
37
37
|
else
|
|
38
|
-
|
|
39
|
-
Ductwork::Tuple.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if result.rows.none?
|
|
38
|
+
begin
|
|
39
|
+
Ductwork::Tuple.create!(attributes)
|
|
40
|
+
rescue ActiveRecord::RecordNotUnique
|
|
43
41
|
raise Ductwork::Context::OverwriteError, "Can only set value once"
|
|
44
42
|
end
|
|
45
43
|
end
|
|
@@ -49,6 +47,6 @@ module Ductwork
|
|
|
49
47
|
|
|
50
48
|
private
|
|
51
49
|
|
|
52
|
-
attr_reader :
|
|
50
|
+
attr_reader :run_id
|
|
53
51
|
end
|
|
54
52
|
end
|