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
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
module Processes
|
|
5
|
-
class ProcessSupervisor
|
|
5
|
+
class ProcessSupervisor # rubocop:todo Metrics/ClassLength
|
|
6
|
+
# Upper bound on how long sig_kill_process will block the supervisor
|
|
7
|
+
# loop waiting for a KILL'd child to be reaped (see sig_kill_process).
|
|
8
|
+
KILL_REAP_TIMEOUT = 5 # seconds
|
|
9
|
+
KILL_REAP_INTERVAL = 0.1
|
|
10
|
+
|
|
6
11
|
attr_reader :workers
|
|
7
12
|
|
|
8
13
|
def initialize
|
|
@@ -29,6 +34,8 @@ module Ductwork
|
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
def run
|
|
37
|
+
adopt_or_create_process!
|
|
38
|
+
|
|
32
39
|
Ductwork.logger.debug(
|
|
33
40
|
msg: "Entering main work loop",
|
|
34
41
|
role: :process_supervisor,
|
|
@@ -38,6 +45,7 @@ module Ductwork
|
|
|
38
45
|
while running_context.running?
|
|
39
46
|
sleep(Ductwork.configuration.supervisor_polling_timeout)
|
|
40
47
|
check_workers
|
|
48
|
+
reap_process_records
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
shutdown
|
|
@@ -60,6 +68,12 @@ module Ductwork
|
|
|
60
68
|
|
|
61
69
|
attr_reader :running_context
|
|
62
70
|
|
|
71
|
+
def adopt_or_create_process!
|
|
72
|
+
Ductwork.wrap_with_app_executor do
|
|
73
|
+
Ductwork::Process.adopt_or_create_current!(:supervisor)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
63
77
|
def check_workers
|
|
64
78
|
Ductwork.logger.debug(
|
|
65
79
|
msg: "Checking workers are alive",
|
|
@@ -67,8 +81,10 @@ module Ductwork
|
|
|
67
81
|
)
|
|
68
82
|
|
|
69
83
|
workers.each do |worker|
|
|
70
|
-
if
|
|
84
|
+
if heartbeat_stale?(worker[:pid])
|
|
71
85
|
old_pid = worker[:pid]
|
|
86
|
+
sig_kill_process(old_pid)
|
|
87
|
+
reap_process_record!(old_pid)
|
|
72
88
|
new_pid = fork do
|
|
73
89
|
worker[:block].call(worker[:metadata])
|
|
74
90
|
end
|
|
@@ -86,6 +102,27 @@ module Ductwork
|
|
|
86
102
|
msg: "All workers are alive or revived",
|
|
87
103
|
role: :process_supervisor
|
|
88
104
|
)
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
Ductwork.logger.warn(
|
|
107
|
+
msg: "Checking workers failed",
|
|
108
|
+
error_klass: e.class.to_s,
|
|
109
|
+
error_message: e.message,
|
|
110
|
+
role: :process_supervisor
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def reap_process_records
|
|
115
|
+
Ductwork.wrap_with_app_executor do
|
|
116
|
+
Ductwork::Process.reap_all!(:process_supervisor)
|
|
117
|
+
Ductwork::Process.sweep_orphaned_claims!(:process_supervisor)
|
|
118
|
+
end
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
Ductwork.logger.warn(
|
|
121
|
+
msg: "Reaping process records failed",
|
|
122
|
+
error_klass: e.class.to_s,
|
|
123
|
+
error_message: e.message,
|
|
124
|
+
role: :process_supervisor
|
|
125
|
+
)
|
|
89
126
|
end
|
|
90
127
|
|
|
91
128
|
def terminate_gracefully
|
|
@@ -97,6 +134,8 @@ module Ductwork
|
|
|
97
134
|
signal: :TERM
|
|
98
135
|
)
|
|
99
136
|
::Process.kill(:TERM, worker[:pid])
|
|
137
|
+
rescue Errno::ESRCH
|
|
138
|
+
# Child already exited; nothing to terminate
|
|
100
139
|
end
|
|
101
140
|
end
|
|
102
141
|
|
|
@@ -121,38 +160,76 @@ module Ductwork
|
|
|
121
160
|
|
|
122
161
|
def terminate_immediately
|
|
123
162
|
workers.each_with_index do |worker, index|
|
|
124
|
-
Ductwork.logger.
|
|
163
|
+
Ductwork.logger.warn(
|
|
125
164
|
msg: "Sending KILL signal to process (#{worker[:pid]})",
|
|
126
165
|
role: :process_supervisor,
|
|
127
166
|
pid: worker[:pid],
|
|
128
167
|
signal: :KILL
|
|
129
168
|
)
|
|
130
|
-
|
|
131
|
-
|
|
169
|
+
sig_kill_process(worker[:pid])
|
|
170
|
+
reap_process_record!(worker[:pid])
|
|
132
171
|
workers[index] = nil
|
|
133
|
-
Ductwork.logger.
|
|
172
|
+
Ductwork.logger.warn(
|
|
134
173
|
msg: "Child process (#{worker[:pid]}) killed after timeout",
|
|
135
174
|
role: :process_supervisor,
|
|
136
175
|
pid: worker[:pid]
|
|
137
176
|
)
|
|
138
|
-
rescue Errno::ESRCH, Errno::ECHILD
|
|
139
|
-
# no-op because process is already dead
|
|
140
177
|
end
|
|
141
178
|
|
|
142
179
|
@workers = workers.compact
|
|
143
180
|
end
|
|
144
181
|
|
|
145
|
-
def
|
|
182
|
+
def heartbeat_stale?(pid)
|
|
146
183
|
machine_identifier = Ductwork::MachineIdentifier.fetch
|
|
184
|
+
threshold = Ductwork.configuration.supervisor_reaper_timeout - 10.seconds
|
|
185
|
+
sql = Ductwork::DatabaseClock.ago_sql("last_heartbeat_at", threshold)
|
|
147
186
|
|
|
148
187
|
Ductwork.wrap_with_app_executor do
|
|
149
188
|
Ductwork::Process
|
|
150
189
|
.where(pid:, machine_identifier:)
|
|
151
|
-
.where(
|
|
190
|
+
.where(sql)
|
|
152
191
|
.exists?
|
|
153
192
|
end
|
|
154
193
|
end
|
|
155
194
|
|
|
195
|
+
def sig_kill_process(pid)
|
|
196
|
+
::Process.kill(:KILL, pid)
|
|
197
|
+
|
|
198
|
+
deadline = now + KILL_REAP_TIMEOUT
|
|
199
|
+
loop do
|
|
200
|
+
return if ::Process.wait(pid, ::Process::WNOHANG)
|
|
201
|
+
|
|
202
|
+
if now >= deadline
|
|
203
|
+
# Likely wedged in uninterruptible sleep (D state): the SIGKILL is
|
|
204
|
+
# pending but undeliverable until its syscall returns. Don't block
|
|
205
|
+
# the supervisor loop on it - the kill still applies, but the OS
|
|
206
|
+
# process is left unreaped (a zombie) until the supervisor itself
|
|
207
|
+
# exits and init reaps it. The DB process record is reaped
|
|
208
|
+
# separately by the caller.
|
|
209
|
+
Ductwork.logger.warn(
|
|
210
|
+
msg: "Process (#{pid}) did not exit after KILL; deferring reap",
|
|
211
|
+
role: :process_supervisor,
|
|
212
|
+
pid: pid
|
|
213
|
+
)
|
|
214
|
+
return
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
sleep(KILL_REAP_INTERVAL)
|
|
218
|
+
end
|
|
219
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
220
|
+
# Process is already dead or reaped
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def reap_process_record!(pid)
|
|
224
|
+
machine_identifier = Ductwork::MachineIdentifier.fetch
|
|
225
|
+
|
|
226
|
+
Ductwork.wrap_with_app_executor do
|
|
227
|
+
Ductwork::Process
|
|
228
|
+
.find_by(pid:, machine_identifier:)
|
|
229
|
+
&.reap!(:process_supervisor, force: true)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
156
233
|
def run_hooks_for(event)
|
|
157
234
|
Ductwork.hooks[:supervisor].fetch(event, []).each do |block|
|
|
158
235
|
Ductwork.wrap_with_app_executor do
|
|
@@ -9,6 +9,7 @@ module Ductwork
|
|
|
9
9
|
@running_context = Ductwork::RunningContext.new
|
|
10
10
|
@workers = []
|
|
11
11
|
|
|
12
|
+
adopt_or_create_process!
|
|
12
13
|
run_hooks_for(:start)
|
|
13
14
|
|
|
14
15
|
Signal.trap(:INT) { @running_context.shutdown! }
|
|
@@ -48,7 +49,9 @@ module Ductwork
|
|
|
48
49
|
|
|
49
50
|
while running_context.running?
|
|
50
51
|
sleep(Ductwork.configuration.supervisor_polling_timeout)
|
|
51
|
-
|
|
52
|
+
check_workers_health
|
|
53
|
+
report_heartbeat!
|
|
54
|
+
reap_process_records
|
|
52
55
|
end
|
|
53
56
|
|
|
54
57
|
shutdown
|
|
@@ -58,26 +61,15 @@ module Ductwork
|
|
|
58
61
|
|
|
59
62
|
attr_reader :running_context
|
|
60
63
|
|
|
61
|
-
def
|
|
62
|
-
Ductwork
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
Ductwork.logger.warn(
|
|
72
|
-
msg: "Restarted supervised thread",
|
|
73
|
-
role: :thread_supervisor,
|
|
74
|
-
thread: worker.name
|
|
75
|
-
)
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
Ductwork.logger.debug(
|
|
80
|
-
msg: "Checked workers are alive",
|
|
64
|
+
def check_workers_health
|
|
65
|
+
Ductwork::Processes::WorkerHealthCheck
|
|
66
|
+
.new(workers, :thread_supervisor)
|
|
67
|
+
.check
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Ductwork.logger.warn(
|
|
70
|
+
msg: "Checking worker health failed",
|
|
71
|
+
error_klass: e.class.to_s,
|
|
72
|
+
error_message: e.message,
|
|
81
73
|
role: :thread_supervisor
|
|
82
74
|
)
|
|
83
75
|
end
|
|
@@ -88,6 +80,7 @@ module Ductwork
|
|
|
88
80
|
workers.each(&:stop)
|
|
89
81
|
await_threads_graceful_shutdown
|
|
90
82
|
kill_remaining_threads
|
|
83
|
+
reap_process_record!
|
|
91
84
|
run_hooks_for(:stop)
|
|
92
85
|
end
|
|
93
86
|
|
|
@@ -130,6 +123,47 @@ module Ductwork
|
|
|
130
123
|
end
|
|
131
124
|
end
|
|
132
125
|
|
|
126
|
+
def adopt_or_create_process!
|
|
127
|
+
Ductwork.wrap_with_app_executor do
|
|
128
|
+
Ductwork::Process.adopt_or_create_current!(:supervisor)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def report_heartbeat!
|
|
133
|
+
Ductwork.logger.debug(msg: "Reporting heartbeat", role: :thread_supervisor)
|
|
134
|
+
Ductwork.wrap_with_app_executor do
|
|
135
|
+
Ductwork::Process.report_heartbeat!(:supervisor)
|
|
136
|
+
end
|
|
137
|
+
Ductwork.logger.debug(msg: "Reported heartbeat", role: :thread_supervisor)
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
Ductwork.logger.warn(
|
|
140
|
+
msg: "Reporting heartbeat failed",
|
|
141
|
+
error_klass: e.class.to_s,
|
|
142
|
+
error_message: e.message,
|
|
143
|
+
role: :thread_supervisor
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def reap_process_records
|
|
148
|
+
Ductwork.wrap_with_app_executor do
|
|
149
|
+
Ductwork::Process.reap_all!(:thread_supervisor)
|
|
150
|
+
Ductwork::Process.sweep_orphaned_claims!(:thread_supervisor)
|
|
151
|
+
end
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
Ductwork.logger.warn(
|
|
154
|
+
msg: "Reaping process records failed",
|
|
155
|
+
error_klass: e.class.to_s,
|
|
156
|
+
error_message: e.message,
|
|
157
|
+
role: :thread_supervisor
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def reap_process_record!
|
|
162
|
+
Ductwork.wrap_with_app_executor do
|
|
163
|
+
Ductwork::Process.current&.reap!(:thread_supervisor, force: true)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
133
167
|
def run_hooks_for(event)
|
|
134
168
|
Ductwork.hooks[:supervisor].fetch(event, []).each do |block|
|
|
135
169
|
Ductwork.wrap_with_app_executor do
|
|
@@ -11,9 +11,11 @@ module Ductwork
|
|
|
11
11
|
def run
|
|
12
12
|
if Ductwork.configuration.role.in?(%w[all advancer])
|
|
13
13
|
pipelines.each do |pipeline|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
Ductwork.configuration.pipeline_advancer_count(pipeline).times do |index|
|
|
15
|
+
log_created_pipeline_advancer(pipeline, index)
|
|
16
|
+
supervisor.add_worker(metadata: { pipeline: }) do
|
|
17
|
+
pipeline_advancer.new(pipeline, index)
|
|
18
|
+
end
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
end
|
|
@@ -36,11 +38,12 @@ module Ductwork
|
|
|
36
38
|
|
|
37
39
|
attr_reader :pipelines, :supervisor
|
|
38
40
|
|
|
39
|
-
def log_created_pipeline_advancer(pipeline)
|
|
41
|
+
def log_created_pipeline_advancer(pipeline, index)
|
|
40
42
|
Ductwork.logger.debug(
|
|
41
43
|
msg: "Created new pipeline advancer",
|
|
42
44
|
role: :thread_supervisor_runner,
|
|
43
|
-
pipeline: pipeline
|
|
45
|
+
pipeline: pipeline,
|
|
46
|
+
index: index
|
|
44
47
|
)
|
|
45
48
|
end
|
|
46
49
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
module Processes
|
|
5
|
+
class WorkerHealthCheck
|
|
6
|
+
def initialize(workers, role)
|
|
7
|
+
@workers = workers
|
|
8
|
+
@role = role
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def check
|
|
12
|
+
workers.each do |worker|
|
|
13
|
+
if !worker.alive?
|
|
14
|
+
restart_dead_worker(worker)
|
|
15
|
+
elsif worker.stuck?
|
|
16
|
+
restart_stuck_worker(worker)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :workers, :role
|
|
24
|
+
|
|
25
|
+
def restart_dead_worker(worker)
|
|
26
|
+
worker.restart
|
|
27
|
+
|
|
28
|
+
claimed_args = if worker.is_a?(Ductwork::Processes::PipelineAdvancer)
|
|
29
|
+
{ branch_id: worker.branch&.id }
|
|
30
|
+
else
|
|
31
|
+
{ job_id: worker.execution&.job_id }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Ductwork.logger.warn(
|
|
35
|
+
msg: "Restarted dead thread",
|
|
36
|
+
role: role,
|
|
37
|
+
thread: worker.name,
|
|
38
|
+
**claimed_args
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def restart_stuck_worker(worker)
|
|
43
|
+
worker.kill if worker.alive?
|
|
44
|
+
worker.join(1)
|
|
45
|
+
worker.restart
|
|
46
|
+
|
|
47
|
+
Ductwork.logger.warn(
|
|
48
|
+
msg: "Killed and restarted stuck thread",
|
|
49
|
+
role: role,
|
|
50
|
+
thread: worker.name
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
class RowLockingExecutionClaim
|
|
5
|
+
def initialize(klass, owner_process_id)
|
|
6
|
+
@availability = nil
|
|
7
|
+
@execution = nil
|
|
8
|
+
@klass = klass
|
|
9
|
+
@process_id = owner_process_id
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def latest
|
|
13
|
+
Ductwork::Record.transaction do
|
|
14
|
+
claim_availability
|
|
15
|
+
|
|
16
|
+
if availability.present?
|
|
17
|
+
Ductwork.logger.debug(
|
|
18
|
+
msg: "Job claimed",
|
|
19
|
+
role: :job_worker,
|
|
20
|
+
process_id: process_id,
|
|
21
|
+
availability_id: availability.id
|
|
22
|
+
)
|
|
23
|
+
update_state
|
|
24
|
+
else
|
|
25
|
+
Ductwork.logger.debug(
|
|
26
|
+
msg: "No available job to claim",
|
|
27
|
+
role: :job_worker,
|
|
28
|
+
process_id: process_id,
|
|
29
|
+
pipeline: klass
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
execution
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :availability, :execution, :klass, :process_id
|
|
40
|
+
|
|
41
|
+
def claim_availability
|
|
42
|
+
sql = Ductwork::DatabaseClock.now_sql("ductwork_availabilities.started_at")
|
|
43
|
+
@availability = Ductwork::Availability
|
|
44
|
+
.where(sql)
|
|
45
|
+
.where(completed_at: nil, pipeline_klass: klass)
|
|
46
|
+
.order(:started_at)
|
|
47
|
+
.lock("FOR UPDATE SKIP LOCKED")
|
|
48
|
+
.limit(1)
|
|
49
|
+
.first
|
|
50
|
+
|
|
51
|
+
return unless availability
|
|
52
|
+
|
|
53
|
+
completed_at = Time.current
|
|
54
|
+
@execution = availability.execution
|
|
55
|
+
|
|
56
|
+
availability.update_columns(completed_at:, process_id:)
|
|
57
|
+
execution.update_columns(process_id:)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update_state
|
|
61
|
+
step = execution.job.step
|
|
62
|
+
|
|
63
|
+
Ductwork::Step
|
|
64
|
+
.where(id: step.id)
|
|
65
|
+
.where.not(status: "in_progress")
|
|
66
|
+
.update_all(status: "in_progress", updated_at: Time.current)
|
|
67
|
+
Ductwork::Run
|
|
68
|
+
.where(id: step.run_id)
|
|
69
|
+
.where.not(status: "in_progress")
|
|
70
|
+
.update_all(status: "in_progress", updated_at: Time.current)
|
|
71
|
+
Ductwork::Pipeline
|
|
72
|
+
.where(id: Ductwork::Run.where(id: step.run_id).select(:pipeline_id))
|
|
73
|
+
.where.not(status: "in_progress")
|
|
74
|
+
.update_all(status: "in_progress", updated_at: Time.current)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -66,7 +66,7 @@ RSpec::Matchers.define(:have_set_context) do |expected|
|
|
|
66
66
|
supports_block_expectations
|
|
67
67
|
|
|
68
68
|
match do |block|
|
|
69
|
-
ctx = Ductwork::Context.new(
|
|
69
|
+
ctx = Ductwork::Context.new(run_id)
|
|
70
70
|
before_values = expected.map { |k, _| ctx.get(k.to_s) }
|
|
71
71
|
|
|
72
72
|
block.call
|
|
@@ -76,7 +76,21 @@ RSpec::Matchers.define(:have_set_context) do |expected|
|
|
|
76
76
|
before_values.all?(&:nil?) && after_context == expected
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
chain :for_pipeline
|
|
79
|
+
chain :for_pipeline do |pipeline|
|
|
80
|
+
@run_id = pipeline.current_run.id
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
chain :for_run do |given_run_id|
|
|
84
|
+
@run_id = given_run_id
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def run_id
|
|
88
|
+
if @run_id.blank?
|
|
89
|
+
raise ArgumentError, "Must chain with .for_pipeline or .for_run"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@run_id
|
|
93
|
+
end
|
|
80
94
|
|
|
81
95
|
failure_message do
|
|
82
96
|
"Context does not match expected result"
|
|
@@ -88,25 +102,28 @@ module Ductwork
|
|
|
88
102
|
module RSpec
|
|
89
103
|
def pipeline_for(klass, **attrs)
|
|
90
104
|
definition = klass.pipeline_definition.to_json
|
|
91
|
-
definition_sha1 =
|
|
105
|
+
definition_sha1 = Digest::SHA1.hexdigest(definition)
|
|
106
|
+
pipeline_klass = klass.name.to_s
|
|
107
|
+
now = Time.current
|
|
92
108
|
status = attrs[:status] || "in_progress"
|
|
93
|
-
triggered_at = attrs[:triggered_at] ||
|
|
94
|
-
started_at = attrs[:started_at] ||
|
|
95
|
-
last_advanced_at = attrs[:last_advanced_at] || Time.current
|
|
109
|
+
triggered_at = attrs[:triggered_at] || now
|
|
110
|
+
started_at = attrs[:started_at] || now
|
|
96
111
|
|
|
97
|
-
Ductwork::Pipeline.create!(
|
|
98
|
-
|
|
112
|
+
pipeline = Ductwork::Pipeline.create!(klass:, status:)
|
|
113
|
+
pipeline.runs.create!(
|
|
114
|
+
pipeline_klass:,
|
|
99
115
|
definition:,
|
|
100
116
|
definition_sha1:,
|
|
101
117
|
status:,
|
|
102
118
|
triggered_at:,
|
|
103
|
-
started_at
|
|
104
|
-
last_advanced_at:
|
|
119
|
+
started_at:
|
|
105
120
|
)
|
|
121
|
+
|
|
122
|
+
pipeline
|
|
106
123
|
end
|
|
107
124
|
|
|
108
125
|
def set_pipeline_context(pipeline, **key_values)
|
|
109
|
-
ctx = Ductwork::Context.new(pipeline.id)
|
|
126
|
+
ctx = Ductwork::Context.new(pipeline.current_run.id)
|
|
110
127
|
key_values.each do |key, value|
|
|
111
128
|
ctx.set(key.to_s, value)
|
|
112
129
|
end
|
data/lib/ductwork/version.rb
CHANGED
data/lib/ductwork.rb
CHANGED
|
@@ -65,6 +65,7 @@ module Ductwork
|
|
|
65
65
|
def validate!
|
|
66
66
|
step_directory = Rails.root.join("app/steps")
|
|
67
67
|
pipeline_directory = Rails.root.join("app/pipelines")
|
|
68
|
+
workflow_directory = Rails.root.join("app/workflows")
|
|
68
69
|
loader = if defined?(Rails.autoloaders)
|
|
69
70
|
Rails.autoloaders.main
|
|
70
71
|
else
|
|
@@ -79,6 +80,10 @@ module Ductwork
|
|
|
79
80
|
loader.eager_load_dir(pipeline_directory)
|
|
80
81
|
end
|
|
81
82
|
|
|
83
|
+
if workflow_directory.exist?
|
|
84
|
+
loader.eager_load_dir(workflow_directory)
|
|
85
|
+
end
|
|
86
|
+
|
|
82
87
|
true
|
|
83
88
|
end
|
|
84
89
|
|
|
@@ -17,20 +17,30 @@ module Ductwork
|
|
|
17
17
|
|
|
18
18
|
migration_template "db/create_ductwork_pipelines.rb",
|
|
19
19
|
"db/migrate/create_ductwork_pipelines.rb"
|
|
20
|
+
migration_template "db/create_ductwork_runs.rb",
|
|
21
|
+
"db/migrate/create_ductwork_runs.rb"
|
|
22
|
+
migration_template "db/create_ductwork_branches.rb",
|
|
23
|
+
"db/migrate/create_ductwork_branches.rb"
|
|
24
|
+
migration_template "db/create_ductwork_branch_links.rb",
|
|
25
|
+
"db/migrate/create_ductwork_branch_links.rb"
|
|
20
26
|
migration_template "db/create_ductwork_steps.rb",
|
|
21
27
|
"db/migrate/create_ductwork_steps.rb"
|
|
22
28
|
migration_template "db/create_ductwork_jobs.rb",
|
|
23
29
|
"db/migrate/create_ductwork_jobs.rb"
|
|
30
|
+
migration_template "db/create_ductwork_processes.rb",
|
|
31
|
+
"db/migrate/create_ductwork_processes.rb"
|
|
24
32
|
migration_template "db/create_ductwork_executions.rb",
|
|
25
33
|
"db/migrate/create_ductwork_executions.rb"
|
|
26
34
|
migration_template "db/create_ductwork_availabilities.rb",
|
|
27
35
|
"db/migrate/create_ductwork_availabilities.rb"
|
|
28
|
-
migration_template "db/
|
|
29
|
-
"db/migrate/
|
|
36
|
+
migration_template "db/create_ductwork_attempts.rb",
|
|
37
|
+
"db/migrate/create_ductwork_attempts.rb"
|
|
30
38
|
migration_template "db/create_ductwork_results.rb",
|
|
31
39
|
"db/migrate/create_ductwork_results.rb"
|
|
32
|
-
migration_template "db/
|
|
33
|
-
"db/migrate/
|
|
40
|
+
migration_template "db/create_ductwork_transitions.rb",
|
|
41
|
+
"db/migrate/create_ductwork_transitions.rb"
|
|
42
|
+
migration_template "db/create_ductwork_advancements.rb",
|
|
43
|
+
"db/migrate/create_ductwork_advancements.rb"
|
|
34
44
|
migration_template "db/create_ductwork_tuples.rb",
|
|
35
45
|
"db/migrate/create_ductwork_tuples.rb"
|
|
36
46
|
|
|
@@ -9,12 +9,16 @@ default: &default
|
|
|
9
9
|
polling_timeout: 1
|
|
10
10
|
shutdown_timeout: 20
|
|
11
11
|
pipeline_advancer:
|
|
12
|
+
count: 3
|
|
13
|
+
max_crash: 10
|
|
14
|
+
max_retry: 3
|
|
12
15
|
polling_timeout: 1
|
|
13
16
|
shutdown_timeout: 20
|
|
14
17
|
steps:
|
|
15
18
|
max_depth: -1
|
|
16
19
|
supervisor:
|
|
17
20
|
polling_timeout: 1
|
|
21
|
+
reaper_timeout: 300
|
|
18
22
|
shutdown_timeout: 30
|
|
19
23
|
|
|
20
24
|
development:
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateDuctworkAdvancements < Ductwork::Migration
|
|
4
|
+
def change
|
|
5
|
+
create_ductwork_table :ductwork_advancements do |table|
|
|
6
|
+
belongs_to(
|
|
7
|
+
table,
|
|
8
|
+
:process,
|
|
9
|
+
index: true,
|
|
10
|
+
null: true,
|
|
11
|
+
foreign_key: { to_table: :ductwork_processes }
|
|
12
|
+
)
|
|
13
|
+
belongs_to(
|
|
14
|
+
table,
|
|
15
|
+
:transition,
|
|
16
|
+
index: true,
|
|
17
|
+
null: false,
|
|
18
|
+
foreign_key: { to_table: :ductwork_transitions }
|
|
19
|
+
)
|
|
20
|
+
table.datetime :started_at, null: false
|
|
21
|
+
table.datetime :completed_at
|
|
22
|
+
table.integer :crash_count
|
|
23
|
+
table.string :error_klass
|
|
24
|
+
table.string :error_message
|
|
25
|
+
table.text :error_backtrace
|
|
26
|
+
table.timestamps null: false
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateDuctworkAttempts < Ductwork::Migration
|
|
4
|
+
def change
|
|
5
|
+
create_ductwork_table :ductwork_attempts do |table|
|
|
6
|
+
belongs_to(
|
|
7
|
+
table,
|
|
8
|
+
:execution,
|
|
9
|
+
index: false,
|
|
10
|
+
null: false,
|
|
11
|
+
foreign_key: { to_table: :ductwork_executions }
|
|
12
|
+
)
|
|
13
|
+
table.timestamp :started_at, null: false
|
|
14
|
+
table.timestamp :completed_at
|
|
15
|
+
table.timestamps null: false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :ductwork_attempts, :execution_id, unique: true
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -10,18 +10,31 @@ class CreateDuctworkAvailabilities < Ductwork::Migration
|
|
|
10
10
|
null: false,
|
|
11
11
|
foreign_key: { to_table: :ductwork_executions }
|
|
12
12
|
)
|
|
13
|
+
belongs_to(
|
|
14
|
+
table,
|
|
15
|
+
:process,
|
|
16
|
+
index: true,
|
|
17
|
+
null: true,
|
|
18
|
+
foreign_key: { to_table: :ductwork_processes }
|
|
19
|
+
)
|
|
13
20
|
table.timestamp :started_at, null: false
|
|
14
21
|
table.timestamp :completed_at
|
|
15
|
-
table.integer :process_id
|
|
16
22
|
table.string :pipeline_klass, null: false
|
|
17
23
|
table.timestamps null: false
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
add_index :ductwork_availabilities, :execution_id, unique: true
|
|
21
27
|
add_index :ductwork_availabilities, %i[id process_id]
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
|
|
29
|
+
if mysql?
|
|
30
|
+
add_index :ductwork_availabilities,
|
|
31
|
+
%i[pipeline_klass completed_at started_at],
|
|
32
|
+
name: "index_ductwork_availabilities_on_claim_latest"
|
|
33
|
+
else
|
|
34
|
+
add_index :ductwork_availabilities,
|
|
35
|
+
%i[pipeline_klass started_at],
|
|
36
|
+
name: "index_ductwork_availabilities_on_claim_latest",
|
|
37
|
+
where: "completed_at IS NULL"
|
|
38
|
+
end
|
|
26
39
|
end
|
|
27
40
|
end
|