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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
module Processes
|
|
5
5
|
class PipelineAdvancer
|
|
6
|
-
attr_reader :thread, :last_heartbeat_at, :
|
|
6
|
+
attr_reader :thread, :last_heartbeat_at, :branch
|
|
7
7
|
|
|
8
8
|
def initialize(klass, index = nil)
|
|
9
9
|
@klass = klass
|
|
@@ -18,12 +18,19 @@ module Ductwork
|
|
|
18
18
|
@thread.name = name
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
def restart
|
|
22
|
+
cleanup_dead_thread!
|
|
23
|
+
start
|
|
24
|
+
end
|
|
22
25
|
|
|
23
26
|
def alive?
|
|
24
27
|
thread&.alive? || false
|
|
25
28
|
end
|
|
26
29
|
|
|
30
|
+
def stuck?
|
|
31
|
+
branch.nil? && last_heartbeat_too_long_ago?
|
|
32
|
+
end
|
|
33
|
+
|
|
27
34
|
def stop
|
|
28
35
|
running_context.shutdown!
|
|
29
36
|
end
|
|
@@ -43,9 +50,9 @@ module Ductwork
|
|
|
43
50
|
|
|
44
51
|
private
|
|
45
52
|
|
|
46
|
-
attr_reader :klass, :index, :running_context
|
|
53
|
+
attr_reader :klass, :index, :running_context, :original_claim_token
|
|
47
54
|
|
|
48
|
-
def work_loop
|
|
55
|
+
def work_loop
|
|
49
56
|
run_hooks_for(:start)
|
|
50
57
|
|
|
51
58
|
Ductwork.logger.debug(
|
|
@@ -55,85 +62,27 @@ module Ductwork
|
|
|
55
62
|
)
|
|
56
63
|
|
|
57
64
|
while running_context.running?
|
|
58
|
-
|
|
59
|
-
Ductwork::Pipeline
|
|
60
|
-
.in_progress
|
|
61
|
-
.where(klass: klass, claimed_for_advancing_at: nil)
|
|
62
|
-
.where(steps: Ductwork::Step.where(status: :advancing))
|
|
63
|
-
.where.not(steps: Ductwork::Step.where.not(status: %w[advancing completed]))
|
|
64
|
-
.order(:last_advanced_at)
|
|
65
|
-
.limit(1)
|
|
66
|
-
.pluck(:id)
|
|
67
|
-
.first
|
|
68
|
-
end
|
|
65
|
+
claimed = false
|
|
69
66
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
status: "advancing"
|
|
77
|
-
)
|
|
78
|
-
end
|
|
67
|
+
Ductwork.wrap_with_app_executor do
|
|
68
|
+
claimed = Branch.with_latest_claimed(klass) do |branch, transition, advancement|
|
|
69
|
+
@branch = branch
|
|
70
|
+
@original_claim_token = branch.claim_token
|
|
71
|
+
|
|
72
|
+
branch.advance!(transition, advancement)
|
|
79
73
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
pipeline: klass,
|
|
85
|
-
role: :pipeline_advancer
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
@pipeline = Ductwork.wrap_with_app_executor do
|
|
89
|
-
@pipeline = Ductwork::Pipeline.find(id)
|
|
90
|
-
pipeline.advance!
|
|
91
|
-
|
|
92
|
-
Ductwork.logger.debug(
|
|
93
|
-
msg: "Pipeline advanced",
|
|
94
|
-
pipeline_id: id,
|
|
95
|
-
pipeline: klass,
|
|
96
|
-
role: :pipeline_advancer
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
# rubocop:todo Metrics/BlockNesting
|
|
100
|
-
status = if pipeline.completed?
|
|
101
|
-
"completed"
|
|
102
|
-
elsif pipeline.dampened?
|
|
103
|
-
"dampened"
|
|
104
|
-
else
|
|
105
|
-
"in_progress"
|
|
106
|
-
end
|
|
107
|
-
# rubocop:enable Metrics/BlockNesting
|
|
108
|
-
ensure
|
|
109
|
-
# release the pipeline and set last advanced at so it doesn't
|
|
110
|
-
# block. we're not using a queue so we have to use a db
|
|
111
|
-
# timestamp
|
|
112
|
-
pipeline.update!(
|
|
113
|
-
claimed_for_advancing_at: nil,
|
|
114
|
-
last_advanced_at: Time.current,
|
|
115
|
-
status: status || "in_progress"
|
|
116
|
-
)
|
|
117
|
-
end
|
|
118
|
-
else
|
|
119
|
-
Ductwork.logger.debug(
|
|
120
|
-
msg: "Did not claim pipeline, avoided race condition",
|
|
121
|
-
pipeline_id: id,
|
|
122
|
-
pipeline: klass,
|
|
123
|
-
role: :pipeline_advancer
|
|
124
|
-
)
|
|
74
|
+
Ductwork::FaultInjection.checkpoint(:after_branch_advancement)
|
|
75
|
+
ensure
|
|
76
|
+
@branch = nil
|
|
77
|
+
@original_claim_token = nil
|
|
125
78
|
end
|
|
126
|
-
else
|
|
127
|
-
Ductwork.logger.debug(
|
|
128
|
-
msg: "No pipeline needs advancing",
|
|
129
|
-
pipeline: klass,
|
|
130
|
-
role: :pipeline_advancer
|
|
131
|
-
)
|
|
132
79
|
end
|
|
133
80
|
|
|
134
81
|
@last_heartbeat_at = Time.current
|
|
135
82
|
|
|
136
|
-
|
|
83
|
+
if !claimed
|
|
84
|
+
sleep(polling_timeout)
|
|
85
|
+
end
|
|
137
86
|
end
|
|
138
87
|
|
|
139
88
|
Ductwork.logger.debug(
|
|
@@ -145,6 +94,29 @@ module Ductwork
|
|
|
145
94
|
run_hooks_for(:stop)
|
|
146
95
|
end
|
|
147
96
|
|
|
97
|
+
def cleanup_dead_thread!
|
|
98
|
+
if branch.present?
|
|
99
|
+
advancement = branch
|
|
100
|
+
.transitions
|
|
101
|
+
.joins(:advancements)
|
|
102
|
+
.where(ductwork_advancements: { completed_at: nil })
|
|
103
|
+
.first
|
|
104
|
+
&.advancements
|
|
105
|
+
&.find_by(completed_at: nil)
|
|
106
|
+
|
|
107
|
+
advancement&.thread_crashed!(original_claim_token)
|
|
108
|
+
end
|
|
109
|
+
ensure
|
|
110
|
+
@branch = nil
|
|
111
|
+
@original_claim_token = nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def last_heartbeat_too_long_ago?
|
|
115
|
+
threshold = Ductwork.configuration.supervisor_reaper_timeout
|
|
116
|
+
|
|
117
|
+
(Time.current - last_heartbeat_at) > threshold
|
|
118
|
+
end
|
|
119
|
+
|
|
148
120
|
def run_hooks_for(event)
|
|
149
121
|
Ductwork.hooks[:advancer].fetch(event, []).each do |block|
|
|
150
122
|
Ductwork.wrap_with_app_executor do
|
|
@@ -24,7 +24,7 @@ module Ductwork
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def run
|
|
27
|
-
|
|
27
|
+
adopt_or_create_process!
|
|
28
28
|
start_pipeline_advancers
|
|
29
29
|
|
|
30
30
|
Ductwork.logger.debug(
|
|
@@ -36,7 +36,7 @@ module Ductwork
|
|
|
36
36
|
while running_context.running?
|
|
37
37
|
# TODO: Increase or make configurable
|
|
38
38
|
sleep(5)
|
|
39
|
-
|
|
39
|
+
check_workers_health
|
|
40
40
|
report_heartbeat!
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -49,60 +49,53 @@ module Ductwork
|
|
|
49
49
|
|
|
50
50
|
def start_pipeline_advancers
|
|
51
51
|
klasses.each do |klass|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
Ductwork.logger.debug(
|
|
57
|
-
msg: "Created new pipeline advancer",
|
|
58
|
-
role: :pipeline_advancer_runner,
|
|
59
|
-
pipeline: klass,
|
|
60
|
-
thread: advancer.name
|
|
61
|
-
)
|
|
62
|
-
end
|
|
63
|
-
end
|
|
52
|
+
Ductwork.configuration.pipeline_advancer_count(klass).times do |index|
|
|
53
|
+
advancer = Ductwork::Processes::PipelineAdvancer.new(klass, index)
|
|
54
|
+
advancers.push(advancer)
|
|
55
|
+
advancer.start
|
|
64
56
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
msg: "Checking threads health",
|
|
68
|
-
role: :pipeline_advancer_runner,
|
|
69
|
-
pipelines: klasses
|
|
70
|
-
)
|
|
71
|
-
advancers.each do |advancer|
|
|
72
|
-
if !advancer.alive?
|
|
73
|
-
advancer.restart
|
|
74
|
-
|
|
75
|
-
Ductwork.logger.warn(
|
|
76
|
-
msg: "Restarted pipeline advancer",
|
|
57
|
+
Ductwork.logger.debug(
|
|
58
|
+
msg: "Created new pipeline advancer",
|
|
77
59
|
role: :pipeline_advancer_runner,
|
|
78
|
-
pipeline:
|
|
60
|
+
pipeline: klass,
|
|
79
61
|
thread: advancer.name
|
|
80
62
|
)
|
|
81
63
|
end
|
|
82
64
|
end
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_workers_health
|
|
68
|
+
Ductwork::Processes::WorkerHealthCheck
|
|
69
|
+
.new(advancers, :pipeline_advancer_runner)
|
|
70
|
+
.check
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
Ductwork.logger.warn(
|
|
73
|
+
msg: "Checking worker health failed",
|
|
74
|
+
error_klass: e.class.to_s,
|
|
75
|
+
error_message: e.message,
|
|
76
|
+
role: :pipeline_advancer_runner
|
|
87
77
|
)
|
|
88
78
|
end
|
|
89
79
|
|
|
90
|
-
def
|
|
80
|
+
def adopt_or_create_process!
|
|
91
81
|
Ductwork.wrap_with_app_executor do
|
|
92
|
-
Ductwork::Process.
|
|
93
|
-
pid: ::Process.pid,
|
|
94
|
-
machine_identifier: Ductwork::MachineIdentifier.fetch,
|
|
95
|
-
last_heartbeat_at: Time.current
|
|
96
|
-
)
|
|
82
|
+
Ductwork::Process.adopt_or_create_current!(:pipeline_advancer)
|
|
97
83
|
end
|
|
98
84
|
end
|
|
99
85
|
|
|
100
86
|
def report_heartbeat!
|
|
101
87
|
Ductwork.logger.debug(msg: "Reporting heartbeat", role: :pipeline_advancer_runner)
|
|
102
88
|
Ductwork.wrap_with_app_executor do
|
|
103
|
-
Ductwork::Process.report_heartbeat!
|
|
89
|
+
Ductwork::Process.report_heartbeat!(:pipeline_advancer)
|
|
104
90
|
end
|
|
105
91
|
Ductwork.logger.debug(msg: "Reported heartbeat", role: :pipeline_advancer_runner)
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
Ductwork.logger.warn(
|
|
94
|
+
msg: "Reporting heartbeat failed",
|
|
95
|
+
error_klass: e.class.to_s,
|
|
96
|
+
error_message: e.message,
|
|
97
|
+
role: :pipeline_advancer_runner
|
|
98
|
+
)
|
|
106
99
|
end
|
|
107
100
|
|
|
108
101
|
def shutdown!
|
|
@@ -111,7 +104,7 @@ module Ductwork
|
|
|
111
104
|
advancers.each(&:stop)
|
|
112
105
|
await_threads_graceful_shutdown
|
|
113
106
|
kill_remaining_threads
|
|
114
|
-
|
|
107
|
+
reap_process_record!
|
|
115
108
|
end
|
|
116
109
|
|
|
117
110
|
def log_shutting_down
|
|
@@ -154,12 +147,9 @@ module Ductwork
|
|
|
154
147
|
end
|
|
155
148
|
end
|
|
156
149
|
|
|
157
|
-
def
|
|
150
|
+
def reap_process_record!
|
|
158
151
|
Ductwork.wrap_with_app_executor do
|
|
159
|
-
Ductwork::Process.
|
|
160
|
-
pid: ::Process.pid,
|
|
161
|
-
machine_identifier: Ductwork::MachineIdentifier.fetch
|
|
162
|
-
).delete
|
|
152
|
+
Ductwork::Process.current&.reap!(:pipeline_advancer_runner, force: true)
|
|
163
153
|
end
|
|
164
154
|
end
|
|
165
155
|
end
|
|
@@ -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
|