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.
- checksums.yaml +4 -4
- data/.claude/skills/audit-clock-drift/SKILL.md +167 -5
- data/.claude/skills/audit-common/accepted-tradeoffs.md +118 -0
- data/.claude/skills/audit-common/method.md +86 -0
- data/.claude/skills/audit-common/scope-boundaries.md +101 -0
- data/.claude/skills/audit-common/severity.md +77 -0
- data/.claude/skills/audit-database-indexes/SKILL.md +182 -5
- data/.claude/skills/audit-database-support/SKILL.md +209 -11
- data/.claude/skills/audit-durability/SKILL.md +227 -10
- data/CHANGELOG-PRO.md +17 -1
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +1 -1
- data/lib/ductwork/abandoned_claim.rb +8 -0
- data/lib/ductwork/branch_claim.rb +92 -36
- data/lib/ductwork/claimed_state.rb +57 -0
- data/lib/ductwork/crash.rb +5 -0
- data/lib/ductwork/engine.rb +0 -1
- data/lib/ductwork/models/advancement.rb +11 -5
- data/lib/ductwork/models/branch.rb +91 -18
- data/lib/ductwork/models/execution.rb +41 -14
- data/lib/ductwork/models/pipeline.rb +2 -2
- data/lib/ductwork/models/process.rb +7 -3
- data/lib/ductwork/models/run.rb +2 -1
- data/lib/ductwork/models/step.rb +12 -7
- data/lib/ductwork/optimistic_locking_execution_claim.rb +6 -16
- data/lib/ductwork/orphaned_claim.rb +5 -0
- data/lib/ductwork/polling_interval.rb +30 -0
- data/lib/ductwork/process_crash.rb +5 -0
- data/lib/ductwork/processes/job_worker.rb +25 -6
- data/lib/ductwork/processes/pipeline_advancer.rb +16 -6
- data/lib/ductwork/processes/process_supervisor.rb +21 -0
- data/lib/ductwork/processes/worker_health_check.rb +38 -10
- data/lib/ductwork/row_locking_execution_claim.rb +4 -17
- data/lib/ductwork/thread_crash.rb +5 -0
- data/lib/ductwork/version.rb +1 -1
- data/lib/ductwork.rb +6 -2
- 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:
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
data/lib/ductwork/models/run.rb
CHANGED
|
@@ -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:
|
|
90
|
+
.update_all(on_halt_dispatched_at:, updated_at:)
|
|
90
91
|
|
|
91
92
|
return if claimed.zero?
|
|
92
93
|
|
data/lib/ductwork/models/step.rb
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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,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
|
|
@@ -8,17 +8,25 @@ module Ductwork
|
|
|
8
8
|
def initialize(pipeline, id)
|
|
9
9
|
@pipeline = pipeline
|
|
10
10
|
@id = id
|
|
11
|
-
@running_context =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
73
|
+
outcome = :idle
|
|
66
74
|
|
|
67
75
|
Ductwork.wrap_with_app_executor do
|
|
68
|
-
|
|
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
|
|
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
|
|
31
|
+
claimed_args = claimed_args_for(worker)
|
|
27
32
|
|
|
28
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
data/lib/ductwork/version.rb
CHANGED
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, :
|
|
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
|