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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/audit-clock-drift/SKILL.md +11 -0
  3. data/.claude/skills/audit-database-indexes/SKILL.md +11 -0
  4. data/.claude/skills/audit-database-support/SKILL.md +18 -0
  5. data/.claude/skills/audit-durability/SKILL.md +17 -0
  6. data/.saturnci/database.yml +1 -0
  7. data/CHANGELOG-PRO.md +72 -0
  8. data/CHANGELOG.md +148 -1
  9. data/CLAUDE.md +34 -0
  10. data/README.md +24 -14
  11. data/app/controllers/ductwork/application_controller.rb +3 -3
  12. data/app/controllers/ductwork/dashboards_controller.rb +1 -1
  13. data/app/controllers/ductwork/pipelines_controller.rb +4 -3
  14. data/app/helpers/ductwork/application_helper.rb +2 -2
  15. data/app/views/ductwork/dashboards/show.html.erb +12 -12
  16. data/app/views/ductwork/pipelines/index.html.erb +12 -12
  17. data/app/views/ductwork/pipelines/show.html.erb +13 -13
  18. data/app/views/ductwork/step_errors/index.html.erb +2 -2
  19. data/lib/ductwork/branch_claim.rb +185 -0
  20. data/lib/ductwork/cli.rb +73 -24
  21. data/lib/ductwork/configuration.rb +73 -3
  22. data/lib/ductwork/context.rb +17 -19
  23. data/lib/ductwork/database_clock.rb +84 -0
  24. data/lib/ductwork/dsl/branch_builder.rb +46 -1
  25. data/lib/ductwork/dsl/definition_builder.rb +101 -2
  26. data/lib/ductwork/{job_claim.rb → execution_claim.rb} +7 -6
  27. data/lib/ductwork/fault_injection.rb +35 -0
  28. data/lib/ductwork/migration_helper.rb +1 -1
  29. data/lib/ductwork/models/advancement.rb +48 -0
  30. data/lib/ductwork/models/attempt.rb +9 -0
  31. data/lib/ductwork/models/availability.rb +2 -0
  32. data/lib/ductwork/models/branch.rb +800 -0
  33. data/lib/ductwork/models/branch_link.rb +10 -0
  34. data/lib/ductwork/models/execution.rb +199 -2
  35. data/lib/ductwork/models/job.rb +8 -121
  36. data/lib/ductwork/models/pipeline.rb +152 -255
  37. data/lib/ductwork/models/process.rb +177 -5
  38. data/lib/ductwork/models/result.rb +2 -1
  39. data/lib/ductwork/models/run.rb +119 -1
  40. data/lib/ductwork/models/step.rb +26 -6
  41. data/lib/ductwork/models/transition.rb +15 -0
  42. data/lib/ductwork/models/tuple.rb +1 -4
  43. data/lib/ductwork/models/workflow.rb +3 -0
  44. data/lib/ductwork/optimistic_locking_execution_claim.rb +97 -0
  45. data/lib/ductwork/processes/health_check.rb +87 -0
  46. data/lib/ductwork/processes/job_worker.rb +86 -20
  47. data/lib/ductwork/processes/job_worker_runner.rb +25 -37
  48. data/lib/ductwork/processes/pipeline_advancer.rb +48 -76
  49. data/lib/ductwork/processes/pipeline_advancer_runner.rb +34 -44
  50. data/lib/ductwork/processes/process_supervisor.rb +87 -10
  51. data/lib/ductwork/processes/thread_supervisor.rb +55 -21
  52. data/lib/ductwork/processes/thread_supervisor_runner.rb +8 -5
  53. data/lib/ductwork/processes/worker_health_check.rb +55 -0
  54. data/lib/ductwork/row_locking_execution_claim.rb +77 -0
  55. data/lib/ductwork/testing/rspec.rb +28 -11
  56. data/lib/ductwork/version.rb +1 -1
  57. data/lib/ductwork.rb +5 -0
  58. data/lib/generators/ductwork/install/install_generator.rb +14 -4
  59. data/lib/generators/ductwork/install/templates/config/ductwork.yml +4 -0
  60. data/lib/generators/ductwork/install/templates/db/create_ductwork_advancements.rb +29 -0
  61. data/lib/generators/ductwork/install/templates/db/create_ductwork_attempts.rb +20 -0
  62. data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +18 -5
  63. data/lib/generators/ductwork/install/templates/db/create_ductwork_branch_links.rb +27 -0
  64. data/lib/generators/ductwork/install/templates/db/create_ductwork_branches.rb +37 -0
  65. data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +8 -1
  66. data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +0 -8
  67. data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +2 -0
  68. data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +1 -0
  69. data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +45 -6
  70. data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +20 -5
  71. data/lib/generators/ductwork/install/templates/db/create_ductwork_transitions.rb +43 -0
  72. data/lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb +3 -3
  73. data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_advancements.rb +7 -0
  74. data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_executions.rb +14 -0
  75. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_results.rb +7 -0
  76. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_runs.rb +9 -0
  77. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_transitions.rb +16 -0
  78. data/lib/generators/ductwork/update/templates/db/add_pipeline_started_index_to_ductwork_runs.rb +7 -0
  79. data/lib/generators/ductwork/update/templates/db/add_process_id_to_ductwork_executions.rb +35 -0
  80. data/lib/generators/ductwork/update/templates/db/add_role_to_ductwork_processes.rb +20 -0
  81. data/lib/generators/ductwork/update/templates/db/associate_branches_to_runs.rb +74 -0
  82. data/lib/generators/ductwork/update/templates/db/associate_steps_to_branches.rb +24 -0
  83. data/lib/generators/ductwork/update/templates/db/associate_steps_to_runs.rb +72 -0
  84. data/lib/generators/ductwork/update/templates/db/associate_tuples_to_runs.rb +73 -0
  85. data/lib/generators/ductwork/update/templates/db/backfill_branch_ids_on_steps.rb +21 -0
  86. data/lib/generators/ductwork/update/templates/db/create_ductwork_advancements.rb +28 -0
  87. data/lib/generators/ductwork/update/templates/db/create_ductwork_branch_links.rb +27 -0
  88. data/lib/generators/ductwork/update/templates/db/create_ductwork_branches.rb +37 -0
  89. data/lib/generators/ductwork/update/templates/db/create_ductwork_runs.rb +55 -0
  90. data/lib/generators/ductwork/update/templates/db/create_ductwork_transitions.rb +32 -0
  91. data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb +11 -4
  92. data/lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb +5 -0
  93. data/lib/generators/ductwork/update/templates/db/rename_runs_to_attempts.rb +7 -0
  94. data/lib/generators/ductwork/update/templates/db/update_process_associations.rb +29 -0
  95. data/lib/generators/ductwork/update/update_generator.rb +99 -0
  96. metadata +47 -6
  97. data/lib/ductwork/optimistic_locking_job_claim.rb +0 -88
  98. data/lib/ductwork/row_locking_job_claim.rb +0 -75
@@ -3,7 +3,7 @@
3
3
  module Ductwork
4
4
  module Processes
5
5
  class PipelineAdvancer
6
- attr_reader :thread, :last_heartbeat_at, :pipeline
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
- alias restart start
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 # rubocop:todo Metrics
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
- id = Ductwork.wrap_with_app_executor do
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
- if id.present?
71
- rows_updated = Ductwork.wrap_with_app_executor do
72
- Ductwork::Pipeline
73
- .where(id: id, claimed_for_advancing_at: nil)
74
- .update_all(
75
- claimed_for_advancing_at: Time.current,
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
- if rows_updated == 1
81
- Ductwork.logger.debug(
82
- msg: "Pipeline claimed",
83
- pipeline_id: id,
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
- sleep(polling_timeout)
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
- create_process!
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
- check_thread_health
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
- advancer = Ductwork::Processes::PipelineAdvancer.new(klass)
53
- advancers.push(advancer)
54
- advancer.start
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
- def check_thread_health
66
- Ductwork.logger.debug(
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: advancer.pipeline.class.to_s,
60
+ pipeline: klass,
79
61
  thread: advancer.name
80
62
  )
81
63
  end
82
64
  end
83
- Ductwork.logger.debug(
84
- msg: "Checked thread health",
85
- role: :pipeline_advancer_runner,
86
- pipelines: klasses
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 create_process!
80
+ def adopt_or_create_process!
91
81
  Ductwork.wrap_with_app_executor do
92
- Ductwork::Process.create!(
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
- delete_process!
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 delete_process!
150
+ def reap_process_record!
158
151
  Ductwork.wrap_with_app_executor do
159
- Ductwork::Process.find_by!(
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 process_dead?(worker[:pid])
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.debug(
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
- ::Process.kill(:KILL, worker[:pid])
131
- ::Process.wait(worker[:pid])
169
+ sig_kill_process(worker[:pid])
170
+ reap_process_record!(worker[:pid])
132
171
  workers[index] = nil
133
- Ductwork.logger.debug(
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 process_dead?(pid)
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("last_heartbeat_at < ?", 5.minutes.ago)
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
- check_worker_health
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 check_worker_health
62
- Ductwork.logger.debug(
63
- msg: "Checking workers are alive",
64
- role: :thread_supervisor
65
- )
66
-
67
- workers.each do |worker|
68
- if !worker.alive?
69
- worker.restart
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
- log_created_pipeline_advancer(pipeline)
15
- supervisor.add_worker(metadata: { pipeline: }) do
16
- pipeline_advancer.new(pipeline)
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