ductwork 1.1.0 → 1.1.2

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/audit-clock-drift/SKILL.md +167 -5
  3. data/.claude/skills/audit-common/accepted-tradeoffs.md +118 -0
  4. data/.claude/skills/audit-common/method.md +86 -0
  5. data/.claude/skills/audit-common/scope-boundaries.md +101 -0
  6. data/.claude/skills/audit-common/severity.md +77 -0
  7. data/.claude/skills/audit-database-indexes/SKILL.md +182 -5
  8. data/.claude/skills/audit-database-support/SKILL.md +209 -11
  9. data/.claude/skills/audit-durability/SKILL.md +227 -10
  10. data/CHANGELOG-PRO.md +17 -1
  11. data/CHANGELOG.md +26 -0
  12. data/CLAUDE.md +1 -1
  13. data/lib/ductwork/abandoned_claim.rb +8 -0
  14. data/lib/ductwork/branch_claim.rb +92 -36
  15. data/lib/ductwork/claimed_state.rb +57 -0
  16. data/lib/ductwork/crash.rb +5 -0
  17. data/lib/ductwork/engine.rb +0 -1
  18. data/lib/ductwork/models/advancement.rb +11 -5
  19. data/lib/ductwork/models/branch.rb +91 -18
  20. data/lib/ductwork/models/execution.rb +41 -14
  21. data/lib/ductwork/models/pipeline.rb +2 -2
  22. data/lib/ductwork/models/process.rb +7 -3
  23. data/lib/ductwork/models/run.rb +2 -1
  24. data/lib/ductwork/models/step.rb +12 -7
  25. data/lib/ductwork/optimistic_locking_execution_claim.rb +6 -16
  26. data/lib/ductwork/orphaned_claim.rb +5 -0
  27. data/lib/ductwork/polling_interval.rb +30 -0
  28. data/lib/ductwork/process_crash.rb +5 -0
  29. data/lib/ductwork/processes/job_worker.rb +25 -6
  30. data/lib/ductwork/processes/pipeline_advancer.rb +16 -6
  31. data/lib/ductwork/processes/process_supervisor.rb +21 -0
  32. data/lib/ductwork/processes/worker_health_check.rb +38 -10
  33. data/lib/ductwork/row_locking_execution_claim.rb +4 -17
  34. data/lib/ductwork/thread_crash.rb +5 -0
  35. data/lib/ductwork/version.rb +1 -1
  36. data/lib/ductwork.rb +6 -2
  37. metadata +12 -1
@@ -12,6 +12,12 @@ module Ductwork
12
12
  validates :started_at, presence: true
13
13
 
14
14
  FAILED_EXECUTION_TIMEOUT = 10.seconds
15
+ ERROR_MESSAGE_LIMIT = 255
16
+ # NOTE: `error_backtrace` is a text column, which is unbounded on Postgres
17
+ # and SQLite but caps at 64KB on MySQL. The frames nearest the raise are
18
+ # the diagnostic ones; everything below is the work loop and the framework,
19
+ # identical on every crash
20
+ ERROR_BACKTRACE_LIMIT = 10
15
21
 
16
22
  class CommitFailed < StandardError; end
17
23
 
@@ -23,7 +29,7 @@ module Ductwork
23
29
  job_klass: job.klass
24
30
  )
25
31
  args = JSON.parse(job.input_args)["args"]
26
- instance = Object.const_get(job.klass).build_for_execution(job.step.run_id, *args)
32
+ instance = Object.const_get(job.klass).build_for_execution(job.step.run_id, job.step.id, *args)
27
33
  create_attempt!(started_at: Time.current)
28
34
  output_payload = nil
29
35
 
@@ -52,13 +58,13 @@ module Ductwork
52
58
  end
53
59
 
54
60
  def succeeded!(output_payload, owner_process_id)
55
- completed_at = Time.current
61
+ completed_at = updated_at = Time.current
56
62
  payload = JSON.dump({ payload: output_payload })
57
63
 
58
64
  Ductwork::Record.transaction do
59
65
  rows_updated = Ductwork::Execution
60
66
  .where(id: id, completed_at: nil, process_id: owner_process_id)
61
- .update_all(completed_at:)
67
+ .update_all(completed_at:, updated_at:)
62
68
 
63
69
  if rows_updated.zero?
64
70
  raise Ductwork::Execution::CommitFailed, "Reaper clobbered claimed job execution"
@@ -71,23 +77,25 @@ module Ductwork
71
77
  end
72
78
  end
73
79
 
74
- def crashed! # rubocop:todo Metrics
80
+ def crashed!(error) # rubocop:todo Metrics
75
81
  run = job.step.run
76
82
  max_crash = Ductwork.configuration.job_worker_max_crash(
77
83
  pipeline: run.pipeline_klass,
78
84
  step: job.klass
79
85
  )
80
86
 
87
+ completed_at = updated_at = Time.current
88
+
81
89
  Ductwork::Record.transaction do # rubocop:todo Metrics/BlockLength
82
90
  rows_updated = Ductwork::Execution
83
91
  .where(id: id, process_id: process_id, completed_at: nil)
84
- .update_all(completed_at: Time.current)
92
+ .update_all(completed_at:, updated_at:)
85
93
 
86
94
  return if rows_updated.zero?
87
95
 
88
96
  reload
89
97
  attempt&.update!(completed_at: Time.current)
90
- create_result!(result_type: "process_crashed")
98
+ create_result!(result_type: "process_crashed", **error_attributes(error))
91
99
 
92
100
  if crash_count < max_crash
93
101
  new_crash_count = crash_count + 1
@@ -107,6 +115,8 @@ module Ductwork
107
115
 
108
116
  Ductwork.logger.error(
109
117
  msg: "Job exceeded crash limit and failed",
118
+ error_klass: error.class.to_s,
119
+ error_message: error.message,
110
120
  job_id: job.id,
111
121
  job_klass: job.klass,
112
122
  run_id: run.id,
@@ -118,7 +128,7 @@ module Ductwork
118
128
 
119
129
  def errored!(error, owner_process_id) # rubocop:todo Metrics
120
130
  run = job.step.run
121
- completed_at = Time.current
131
+ completed_at = updated_at = Time.current
122
132
  max_retry = Ductwork.configuration.job_worker_max_retry(
123
133
  pipeline: run.pipeline_klass,
124
134
  step: job.klass
@@ -127,19 +137,14 @@ module Ductwork
127
137
  Ductwork::Record.transaction do # rubocop:todo Metrics/BlockLength
128
138
  rows_updated = Ductwork::Execution
129
139
  .where(id: id, completed_at: nil, process_id: owner_process_id)
130
- .update_all(completed_at:)
140
+ .update_all(completed_at:, updated_at:)
131
141
 
132
142
  if rows_updated.zero?
133
143
  raise Ductwork::Execution::CommitFailed, "Reaper clobbered claimed job execution"
134
144
  end
135
145
 
136
146
  attempt.update!(completed_at: Time.current)
137
- create_result!(
138
- result_type: "failure",
139
- error_klass: error.class.to_s,
140
- error_message: error.message,
141
- error_backtrace: error.backtrace.join("\n")
142
- )
147
+ create_result!(result_type: "failure", **error_attributes(error))
143
148
 
144
149
  if retry_count < max_retry
145
150
  retry_at = Ductwork::DatabaseClock.now + FAILED_EXECUTION_TIMEOUT
@@ -181,6 +186,28 @@ module Ductwork
181
186
 
182
187
  private
183
188
 
189
+ def error_attributes(error)
190
+ {
191
+ error_klass: error.class.to_s,
192
+ error_message: error.message.truncate(ERROR_MESSAGE_LIMIT),
193
+ error_backtrace: truncated_backtrace(error),
194
+ }
195
+ end
196
+
197
+ # NOTE: `backtrace` is nil for a Ductwork::Crash marker, which is built
198
+ # rather than raised -- there is no stack worth capturing for "some other
199
+ # process noticed this claim was dead"
200
+ def truncated_backtrace(error)
201
+ frames = error.backtrace
202
+ return if frames.blank?
203
+
204
+ kept = frames.first(ERROR_BACKTRACE_LIMIT)
205
+ dropped = frames.size - kept.size
206
+ kept << "... #{dropped} more frames" if dropped.positive?
207
+
208
+ kept.join("\n")
209
+ end
210
+
184
211
  # NOTE: the first third (floored) of the crash budget retries immediately
185
212
  # so a one-off reaper clobber / deploy restart recovers fast; past that,
186
213
  # delay linearly so sub-cap crash loops stop hammering the database and
@@ -56,7 +56,7 @@ module Ductwork
56
56
  raise DefinitionError, "Pipeline must be defined before triggering"
57
57
  end
58
58
 
59
- now = Time.current
59
+ now = Ductwork::DatabaseClock.now
60
60
  node = pipeline_definition.dig(:nodes, 0)
61
61
  klass = pipeline_definition.dig(:edges, node, :klass)
62
62
  definition = JSON.dump(pipeline_definition)
@@ -118,7 +118,7 @@ module Ductwork
118
118
  raise ReviveError, "Cannot revive pipeline without previous run"
119
119
  end
120
120
 
121
- now = Time.current
121
+ now = Ductwork::DatabaseClock.now
122
122
  new_run = last_run.dup
123
123
  new_run.triggered_at = now
124
124
  new_run.started_at = now
@@ -60,7 +60,7 @@ module Ductwork
60
60
  role: role
61
61
  )
62
62
 
63
- where(sql).find_each do |process|
63
+ where(sql).where.not(id: current&.id).find_each do |process|
64
64
  process.reap!(role)
65
65
  count += 1
66
66
  end
@@ -105,7 +105,9 @@ module Ductwork
105
105
  .where.not(ductwork_availabilities: { completed_at: nil })
106
106
  .where(execution_sql)
107
107
  .find_each do |execution|
108
- execution.crashed!
108
+ execution.crashed!(
109
+ Ductwork::OrphanedClaim.new("Swept claim with no owning process record")
110
+ )
109
111
  count += 1
110
112
  end
111
113
 
@@ -177,7 +179,9 @@ module Ductwork
177
179
 
178
180
  Ductwork::Record.transaction do
179
181
  advancements.where(completed_at: nil).find_each(&:process_crashed!)
180
- executions.where(completed_at: nil).find_each(&:crashed!)
182
+ executions.where(completed_at: nil).find_each do |execution|
183
+ execution.crashed!(Ductwork::ProcessCrash.new("Reaped from orphaned process"))
184
+ end
181
185
  end
182
186
  end
183
187
 
@@ -84,9 +84,10 @@ module Ductwork
84
84
 
85
85
  return if klass.blank?
86
86
 
87
+ on_halt_dispatched_at = updated_at = Time.current
87
88
  claimed = self.class
88
89
  .where(id: id, status: "halted", on_halt_dispatched_at: nil)
89
- .update_all(on_halt_dispatched_at: Time.current)
90
+ .update_all(on_halt_dispatched_at:, updated_at:)
90
91
 
91
92
  return if claimed.zero?
92
93
 
@@ -2,6 +2,12 @@
2
2
 
3
3
  module Ductwork
4
4
  class Step < Ductwork::Record
5
+ # The statuses that mean this step is done running and its branch is ready
6
+ # to be advanced: `advancing` (the job succeeded) or `failed` (the job
7
+ # exhausted its retry or crash budget). Shared by branch candidate
8
+ # selection, branch claiming, and the guard in `Branch#advance!`.
9
+ ADVANCEABLE_STATUSES = %w[advancing failed].freeze
10
+
5
11
  belongs_to :run, class_name: "Ductwork::Run"
6
12
  belongs_to :branch, class_name: "Ductwork::Branch"
7
13
  belongs_to :source_step, class_name: "Ductwork::Step", optional: true
@@ -34,23 +40,22 @@ module Ductwork
34
40
  converge: "converge",
35
41
  dampen: "dampen"
36
42
 
37
- def self.build_for_execution(run_id, *, **)
43
+ def self.build_for_execution(run_id, idempotency_key, *, **)
38
44
  instance = allocate
39
45
  instance.instance_variable_set(:@run_id, run_id)
46
+ instance.instance_variable_set(:@idempotency_key, idempotency_key)
40
47
  instance.send(:initialize, *, **)
41
48
  instance
42
49
  end
43
50
 
44
- alias_attribute :idempotency_key, :id
45
-
46
51
  def run_id
47
52
  @run_id || (@attributes && super)
48
53
  end
49
54
 
50
- # The result_type of the most recent execution to finish for this step's
51
- # job, used to distinguish *why* a step failed (e.g. `errored!` writes
52
- # "failure", `crashed!` writes "process_crashed"). Returns nil when no
53
- # execution has produced a result yet.
55
+ def idempotency_key
56
+ @idempotency_key || (@attributes && id)
57
+ end
58
+
54
59
  def terminal_result_type
55
60
  return if job.blank?
56
61
 
@@ -24,8 +24,9 @@ module Ductwork
24
24
  availability_id: id
25
25
  )
26
26
 
27
+ updated_at = Time.current
27
28
  @execution = find_execution
28
- execution.update_columns(process_id:)
29
+ execution.update_columns(process_id:, updated_at:)
29
30
 
30
31
  update_state
31
32
  else
@@ -66,9 +67,11 @@ module Ductwork
66
67
  end
67
68
 
68
69
  def claim_availability
70
+ completed_at = updated_at = Ductwork::DatabaseClock.now
71
+
69
72
  Ductwork::Availability
70
73
  .where(id: id, completed_at: nil)
71
- .update_all(completed_at: Time.current, process_id: process_id)
74
+ .update_all(completed_at:, process_id:, updated_at:)
72
75
  end
73
76
 
74
77
  def find_execution
@@ -78,20 +81,7 @@ module Ductwork
78
81
  end
79
82
 
80
83
  def update_state
81
- step = execution.job.step
82
-
83
- Ductwork::Step
84
- .where(id: step.id)
85
- .where.not(status: "in_progress")
86
- .update_all(status: "in_progress", updated_at: Time.current)
87
- Ductwork::Run
88
- .where(id: step.run_id)
89
- .where.not(status: "in_progress")
90
- .update_all(status: "in_progress", updated_at: Time.current)
91
- Ductwork::Pipeline
92
- .where(id: Ductwork::Run.where(id: step.run_id).select(:pipeline_id))
93
- .where.not(status: "in_progress")
94
- .update_all(status: "in_progress", updated_at: Time.current)
84
+ Ductwork::ClaimedState.mark_in_progress!(execution.job.step)
95
85
  end
96
86
  end
97
87
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class OrphanedClaim < Ductwork::Crash; end
5
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ # NOTE: pollers start in lockstep and stay there. A runner spawns its
5
+ # advancer (or worker) threads in a tight loop, so they all reach their first
6
+ # idle `sleep` within microseconds of each other, and because every
7
+ # subsequent sleep is the same fixed interval the phase alignment never
8
+ # decays on its own. Aligned pollers wake together, run the same candidate
9
+ # `SELECT` against the same rows, and all but one lose the claim race -- the
10
+ # very contention that sampling a candidate window in `BranchClaim` is meant
11
+ # to spread out. Spreading the wake-ups spreads the queries.
12
+ #
13
+ # The jitter is re-rolled on every sleep rather than being a per-thread
14
+ # offset chosen once at startup, because a burst of work re-synchronizes the
15
+ # pollers no matter how they were staggered: they all stay busy while the
16
+ # queue drains and then all go idle together the moment it empties.
17
+ #
18
+ # The spread is symmetric so the configured polling timeout stays the average
19
+ # wait. Someone who sets `polling_timeout: 1` still gets a one second average
20
+ # poll latency instead of a quietly slower one.
21
+ module PollingInterval
22
+ JITTER_RATIO = 0.25
23
+
24
+ def self.jittered(timeout)
25
+ spread = timeout * JITTER_RATIO
26
+
27
+ timeout + Kernel.rand(-spread..spread)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class ProcessCrash < Ductwork::Crash; end
5
+ end
@@ -8,17 +8,25 @@ module Ductwork
8
8
  def initialize(pipeline, id)
9
9
  @pipeline = pipeline
10
10
  @id = id
11
- @running_context = Ductwork::RunningContext.new
11
+ @running_context = nil
12
12
  @thread = nil
13
13
  @last_heartbeat_at = Time.current
14
14
  end
15
15
 
16
16
  def start
17
+ return false if alive?
18
+
19
+ @running_context = Ductwork::RunningContext.new
20
+ @last_heartbeat_at = Time.current
17
21
  @thread = Thread.new { work_loop }
18
22
  @thread.name = name
23
+
24
+ true
19
25
  end
20
26
 
21
27
  def restart
28
+ return false if alive?
29
+
22
30
  cleanup_dead_thread!
23
31
  start
24
32
  end
@@ -32,7 +40,7 @@ module Ductwork
32
40
  end
33
41
 
34
42
  def stop
35
- running_context.shutdown!
43
+ running_context&.shutdown!
36
44
  end
37
45
 
38
46
  def kill
@@ -90,6 +98,7 @@ module Ductwork
90
98
  role: :job_worker,
91
99
  pipeline: pipeline
92
100
  )
101
+
93
102
  sleep(polling_timeout)
94
103
  end
95
104
  rescue Ductwork::Execution::CommitFailed => e
@@ -107,7 +116,7 @@ module Ductwork
107
116
  rescue StandardError => e
108
117
  if execution.present?
109
118
  Ductwork.wrap_with_app_executor do
110
- execution.crashed!
119
+ execution.crashed!(e)
111
120
  end
112
121
  end
113
122
 
@@ -121,9 +130,15 @@ module Ductwork
121
130
  pipeline: pipeline
122
131
  )
123
132
  ensure
133
+ # NOTE: the rescue above already labelled anything that raised a
134
+ # StandardError, so reaching here with an uncommitted claim means
135
+ # either that its `crashed!` itself failed or that the iteration
136
+ # left by a path no rescue saw
124
137
  if execution.present? && execution.reload.completed_at.nil?
125
138
  Ductwork.wrap_with_app_executor do
126
- execution.crashed!
139
+ execution.crashed!(
140
+ Ductwork::AbandonedClaim.new("Work loop iteration exited holding an uncommitted claim")
141
+ )
127
142
  rescue StandardError
128
143
  nil
129
144
  end
@@ -146,7 +161,9 @@ module Ductwork
146
161
 
147
162
  def cleanup_dead_thread!
148
163
  if execution.present? && execution.reload.completed_at.nil?
149
- execution.crashed!
164
+ execution.crashed!(
165
+ Ductwork::ThreadCrash.new("Worker thread died holding an uncommitted claim")
166
+ )
150
167
  end
151
168
  ensure
152
169
  @execution = nil
@@ -167,7 +184,9 @@ module Ductwork
167
184
  end
168
185
 
169
186
  def polling_timeout
170
- Ductwork.configuration.job_worker_polling_timeout(pipeline)
187
+ timeout = Ductwork.configuration.job_worker_polling_timeout(pipeline)
188
+
189
+ Ductwork::PollingInterval.jittered(timeout)
171
190
  end
172
191
  end
173
192
  end
@@ -8,17 +8,25 @@ module Ductwork
8
8
  def initialize(klass, index = nil)
9
9
  @klass = klass
10
10
  @index = index || 0
11
- @running_context = Ductwork::RunningContext.new
11
+ @running_context = nil
12
12
  @last_heartbeat_at = Time.current
13
13
  @thread = nil
14
14
  end
15
15
 
16
16
  def start
17
+ return false if alive?
18
+
19
+ @running_context = Ductwork::RunningContext.new
20
+ @last_heartbeat_at = Time.current
17
21
  @thread = Thread.new { work_loop }
18
22
  @thread.name = name
23
+
24
+ true
19
25
  end
20
26
 
21
27
  def restart
28
+ return false if alive?
29
+
22
30
  cleanup_dead_thread!
23
31
  start
24
32
  end
@@ -32,7 +40,7 @@ module Ductwork
32
40
  end
33
41
 
34
42
  def stop
35
- running_context.shutdown!
43
+ running_context&.shutdown!
36
44
  end
37
45
 
38
46
  def kill
@@ -62,10 +70,10 @@ module Ductwork
62
70
  )
63
71
 
64
72
  while running_context.running?
65
- claimed = false
73
+ outcome = :idle
66
74
 
67
75
  Ductwork.wrap_with_app_executor do
68
- claimed = Branch.with_latest_claimed(klass) do |branch, transition, advancement|
76
+ outcome = Branch.with_latest_claimed(klass) do |branch, transition, advancement|
69
77
  @branch = branch
70
78
  @original_claim_token = branch.claim_token
71
79
 
@@ -80,7 +88,7 @@ module Ductwork
80
88
 
81
89
  @last_heartbeat_at = Time.current
82
90
 
83
- if !claimed
91
+ if outcome == :idle
84
92
  sleep(polling_timeout)
85
93
  end
86
94
  end
@@ -126,7 +134,9 @@ module Ductwork
126
134
  end
127
135
 
128
136
  def polling_timeout
129
- Ductwork.configuration.pipeline_polling_timeout(klass)
137
+ timeout = Ductwork.configuration.pipeline_polling_timeout(klass)
138
+
139
+ Ductwork::PollingInterval.jittered(timeout)
130
140
  end
131
141
  end
132
142
  end
@@ -45,6 +45,7 @@ module Ductwork
45
45
  while running_context.running?
46
46
  sleep(Ductwork.configuration.supervisor_polling_timeout)
47
47
  check_workers
48
+ report_heartbeat!
48
49
  reap_process_records
49
50
  end
50
51
 
@@ -61,6 +62,7 @@ module Ductwork
61
62
  terminate_gracefully
62
63
  wait_for_workers_to_exit
63
64
  terminate_immediately
65
+ reap_own_process_record!
64
66
  run_hooks_for(:stop)
65
67
  end
66
68
 
@@ -111,6 +113,19 @@ module Ductwork
111
113
  )
112
114
  end
113
115
 
116
+ def report_heartbeat!
117
+ Ductwork.wrap_with_app_executor do
118
+ Ductwork::Process.report_heartbeat!(:supervisor)
119
+ end
120
+ rescue StandardError => e
121
+ Ductwork.logger.warn(
122
+ msg: "Reporting heartbeat failed",
123
+ error_klass: e.class.to_s,
124
+ error_message: e.message,
125
+ role: :process_supervisor
126
+ )
127
+ end
128
+
114
129
  def reap_process_records
115
130
  Ductwork.wrap_with_app_executor do
116
131
  Ductwork::Process.reap_all!(:process_supervisor)
@@ -230,6 +245,12 @@ module Ductwork
230
245
  end
231
246
  end
232
247
 
248
+ def reap_own_process_record!
249
+ Ductwork.wrap_with_app_executor do
250
+ Ductwork::Process.current&.reap!(:process_supervisor, force: true)
251
+ end
252
+ end
253
+
233
254
  def run_hooks_for(event)
234
255
  Ductwork.hooks[:supervisor].fetch(event, []).each do |block|
235
256
  Ductwork.wrap_with_app_executor do
@@ -3,17 +3,22 @@
3
3
  module Ductwork
4
4
  module Processes
5
5
  class WorkerHealthCheck
6
+ KILL_BUDGET = 3
7
+ JOIN_TIMEOUT = 1
8
+
6
9
  def initialize(workers, role)
7
10
  @workers = workers
8
11
  @role = role
9
12
  end
10
13
 
11
14
  def check
15
+ deadline = Time.current + KILL_BUDGET
16
+
12
17
  workers.each do |worker|
13
18
  if !worker.alive?
14
19
  restart_dead_worker(worker)
15
20
  elsif worker.stuck?
16
- restart_stuck_worker(worker)
21
+ restart_stuck_worker(worker, deadline)
17
22
  end
18
23
  end
19
24
  end
@@ -23,13 +28,9 @@ module Ductwork
23
28
  attr_reader :workers, :role
24
29
 
25
30
  def restart_dead_worker(worker)
26
- worker.restart
31
+ claimed_args = claimed_args_for(worker)
27
32
 
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
+ worker.restart
33
34
 
34
35
  Ductwork.logger.warn(
35
36
  msg: "Restarted dead thread",
@@ -39,9 +40,17 @@ module Ductwork
39
40
  )
40
41
  end
41
42
 
42
- def restart_stuck_worker(worker)
43
- worker.kill if worker.alive?
44
- worker.join(1)
43
+ def restart_stuck_worker(worker, deadline)
44
+ if !dead_after_kill?(worker, deadline)
45
+ Ductwork.logger.warn(
46
+ msg: "Unable to confirm stuck thread died, deferring restart",
47
+ role: role,
48
+ thread: worker.name
49
+ )
50
+
51
+ return
52
+ end
53
+
45
54
  worker.restart
46
55
 
47
56
  Ductwork.logger.warn(
@@ -50,6 +59,25 @@ module Ductwork
50
59
  thread: worker.name
51
60
  )
52
61
  end
62
+
63
+ def dead_after_kill?(worker, deadline)
64
+ worker.kill
65
+
66
+ while worker.alive? && Time.current < deadline
67
+ worker.join(JOIN_TIMEOUT)
68
+ worker.kill if worker.alive?
69
+ end
70
+
71
+ !worker.alive?
72
+ end
73
+
74
+ def claimed_args_for(worker)
75
+ if worker.is_a?(Ductwork::Processes::PipelineAdvancer)
76
+ { branch_id: worker.branch&.id }
77
+ else
78
+ { job_id: worker.execution&.job_id }
79
+ end
80
+ end
53
81
  end
54
82
  end
55
83
  end
@@ -50,28 +50,15 @@ module Ductwork
50
50
 
51
51
  return unless availability
52
52
 
53
- completed_at = Time.current
53
+ completed_at = updated_at = Ductwork::DatabaseClock.now
54
54
  @execution = availability.execution
55
55
 
56
- availability.update_columns(completed_at:, process_id:)
57
- execution.update_columns(process_id:)
56
+ availability.update_columns(completed_at:, process_id:, updated_at:)
57
+ execution.update_columns(process_id:, updated_at:)
58
58
  end
59
59
 
60
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)
61
+ Ductwork::ClaimedState.mark_in_progress!(execution.job.step)
75
62
  end
76
63
  end
77
64
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class ThreadCrash < Ductwork::Crash; end
5
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ductwork
4
- VERSION = "1.1.0"
4
+ VERSION = "1.1.2"
5
5
  end
data/lib/ductwork.rb CHANGED
@@ -11,8 +11,12 @@ require "zeitwerk"
11
11
 
12
12
  module Ductwork
13
13
  class << self
14
- attr_accessor :app_executor, :configuration, :loader, :logger
15
- attr_writer :defined_pipelines, :hooks
14
+ attr_accessor :app_executor, :loader, :logger
15
+ attr_writer :configuration, :defined_pipelines, :hooks
16
+
17
+ def configuration
18
+ @configuration ||= Ductwork::Configuration.new
19
+ end
16
20
 
17
21
  def eager_load
18
22
  loader.eager_load