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
data/lib/ductwork/models/run.rb
CHANGED
|
@@ -2,8 +2,126 @@
|
|
|
2
2
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
class Run < Ductwork::Record
|
|
5
|
-
belongs_to :
|
|
5
|
+
belongs_to :pipeline, class_name: "Ductwork::Pipeline"
|
|
6
|
+
has_many :branches,
|
|
7
|
+
class_name: "Ductwork::Branch",
|
|
8
|
+
foreign_key: "run_id",
|
|
9
|
+
dependent: :destroy
|
|
10
|
+
has_many :steps,
|
|
11
|
+
class_name: "Ductwork::Step",
|
|
12
|
+
foreign_key: "run_id",
|
|
13
|
+
dependent: :destroy
|
|
14
|
+
has_many :tuples,
|
|
15
|
+
class_name: "Ductwork::Tuple",
|
|
16
|
+
foreign_key: "run_id",
|
|
17
|
+
dependent: :destroy
|
|
6
18
|
|
|
19
|
+
validates :pipeline_klass, presence: true
|
|
20
|
+
validates :definition, presence: true
|
|
21
|
+
validates :definition_sha1, presence: true
|
|
22
|
+
validates :status, presence: true
|
|
7
23
|
validates :started_at, presence: true
|
|
24
|
+
validates :triggered_at, presence: true
|
|
25
|
+
|
|
26
|
+
enum :status,
|
|
27
|
+
pending: "pending",
|
|
28
|
+
in_progress: "in_progress",
|
|
29
|
+
waiting: "waiting",
|
|
30
|
+
advancing: "advancing",
|
|
31
|
+
halted: "halted",
|
|
32
|
+
dampened: "dampened",
|
|
33
|
+
completed: "completed"
|
|
34
|
+
|
|
35
|
+
def parsed_definition
|
|
36
|
+
@parsed_definition ||= JSON.parse(definition).with_indifferent_access
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_terminal_state!
|
|
40
|
+
Ductwork::Record.transaction do
|
|
41
|
+
lock_for_terminal_resolution!
|
|
42
|
+
|
|
43
|
+
next if halted? || completed?
|
|
44
|
+
next if branches.where.not(status: %w[completed halted]).exists?
|
|
45
|
+
|
|
46
|
+
if branches.halted.exists?
|
|
47
|
+
pipeline.update!(status: "halted")
|
|
48
|
+
update!(status: "halted", halted_at: Time.current)
|
|
49
|
+
|
|
50
|
+
Ductwork.logger.warn(
|
|
51
|
+
msg: "Pipeline halted",
|
|
52
|
+
pipeline_id: pipeline.id,
|
|
53
|
+
run_id: id
|
|
54
|
+
)
|
|
55
|
+
else
|
|
56
|
+
pipeline.update!(status: "completed")
|
|
57
|
+
update!(status: "completed", completed_at: Time.current)
|
|
58
|
+
|
|
59
|
+
Ductwork.logger.info(
|
|
60
|
+
msg: "Pipeline completed",
|
|
61
|
+
pipeline_id: pipeline.id,
|
|
62
|
+
run_id: id
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# NOTE: Fires the host `on_halt` handler (at-most) once after the run has halted
|
|
69
|
+
#
|
|
70
|
+
# The handler is an observable side effect (it may page, refund, or emit an
|
|
71
|
+
# external event), so it must run only after the halt is durably committed
|
|
72
|
+
# and outside the advancer's `with_claim_fence` transaction; otherwise a
|
|
73
|
+
# rolled-back commit (deadlock victim, dropped connection) would leave it
|
|
74
|
+
# spuriously fired and double-firing on re-claim. `Branch#advance!` calls
|
|
75
|
+
# this once the advancement has committed.
|
|
76
|
+
#
|
|
77
|
+
# The dispatch is claimed with an atomic `UPDATE ... WHERE`: only the
|
|
78
|
+
# advancer that flips `on_halt_dispatched_at` from NULL runs the handler, and
|
|
79
|
+
# only while the run is actually halted (a rolled-back halt never persists
|
|
80
|
+
# `status`). That makes the handler at-most-once even across concurrent
|
|
81
|
+
# advancers and re-claims.
|
|
82
|
+
def dispatch_on_halt!
|
|
83
|
+
klass = parsed_definition.dig(:metadata, :on_halt, :klass)
|
|
84
|
+
|
|
85
|
+
return if klass.blank?
|
|
86
|
+
|
|
87
|
+
claimed = self.class
|
|
88
|
+
.where(id: id, status: "halted", on_halt_dispatched_at: nil)
|
|
89
|
+
.update_all(on_halt_dispatched_at: Time.current)
|
|
90
|
+
|
|
91
|
+
return if claimed.zero?
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
reasons = branches.halted.pluck(:halt_reason)
|
|
95
|
+
|
|
96
|
+
Object.const_get(klass).new(reasons).execute
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Ductwork.logger.error(
|
|
99
|
+
msg: "on_halt handler errored",
|
|
100
|
+
run_id: id,
|
|
101
|
+
error_klass: e.class.to_s,
|
|
102
|
+
error_message: e.message
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# NOTE: a branch transition creates run-referencing rows (branch/step/job)
|
|
110
|
+
# whose FK takes a `FOR KEY SHARE` lock on this run row, then resolves the
|
|
111
|
+
# terminal state in the same transaction. The default `FOR UPDATE` conflicts
|
|
112
|
+
# with `FOR KEY SHARE`, so two transitions each holding the share-lock and
|
|
113
|
+
# upgrading here deadlock on the run row. On Postgres/Cockroach we take
|
|
114
|
+
# `FOR NO KEY UPDATE`, which is compatible with `FOR KEY SHARE` (no upgrade
|
|
115
|
+
# cycle) yet still conflicts with itself (concurrent resolutions stay
|
|
116
|
+
# serialized). MySQL has no equivalent mode, so it keeps `FOR UPDATE` and
|
|
117
|
+
# relies on the advancer retrying a deadlock victim; SQLite has no row-level
|
|
118
|
+
# locks, so `lock!` is a no-op there and the deadlock cannot occur.
|
|
119
|
+
def lock_for_terminal_resolution!
|
|
120
|
+
if Ductwork::Record.connection.adapter_name.downcase.match?(/postgresql|cockroach/)
|
|
121
|
+
lock!("FOR NO KEY UPDATE")
|
|
122
|
+
else
|
|
123
|
+
lock!
|
|
124
|
+
end
|
|
125
|
+
end
|
|
8
126
|
end
|
|
9
127
|
end
|
data/lib/ductwork/models/step.rb
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
class Step < Ductwork::Record
|
|
5
|
-
belongs_to :
|
|
5
|
+
belongs_to :run, class_name: "Ductwork::Run"
|
|
6
|
+
belongs_to :branch, class_name: "Ductwork::Branch"
|
|
7
|
+
belongs_to :source_step, class_name: "Ductwork::Step", optional: true
|
|
8
|
+
has_many :derived_steps, class_name: "Ductwork::Step", foreign_key: :source_step_id, dependent: :destroy
|
|
6
9
|
has_one :job, class_name: "Ductwork::Job", foreign_key: "step_id", dependent: :destroy
|
|
10
|
+
has_one :in_transition, class_name: "Ductwork::Transition", foreign_key: "in_step_id", dependent: :destroy
|
|
11
|
+
has_one :out_transition, class_name: "Ductwork::Transition", foreign_key: "out_step_id", dependent: :destroy
|
|
7
12
|
|
|
8
13
|
validates :node, presence: true
|
|
9
14
|
validates :klass, presence: true
|
|
@@ -29,19 +34,34 @@ module Ductwork
|
|
|
29
34
|
converge: "converge",
|
|
30
35
|
dampen: "dampen"
|
|
31
36
|
|
|
32
|
-
def self.build_for_execution(
|
|
37
|
+
def self.build_for_execution(run_id, *, **)
|
|
33
38
|
instance = allocate
|
|
34
|
-
instance.instance_variable_set(:@
|
|
39
|
+
instance.instance_variable_set(:@run_id, run_id)
|
|
35
40
|
instance.send(:initialize, *, **)
|
|
36
41
|
instance
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
alias_attribute :idempotency_key, :id
|
|
45
|
+
|
|
46
|
+
def run_id
|
|
47
|
+
@run_id || (@attributes && super)
|
|
48
|
+
end
|
|
49
|
+
|
|
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.
|
|
54
|
+
def terminal_result_type
|
|
55
|
+
return if job.blank?
|
|
56
|
+
|
|
57
|
+
job.executions
|
|
58
|
+
.joins(:result)
|
|
59
|
+
.order(created_at: :desc)
|
|
60
|
+
.pick("ductwork_results.result_type")
|
|
41
61
|
end
|
|
42
62
|
|
|
43
63
|
def context
|
|
44
|
-
@_context ||= Ductwork::Context.new(
|
|
64
|
+
@_context ||= Ductwork::Context.new(run_id)
|
|
45
65
|
end
|
|
46
66
|
end
|
|
47
67
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
class Transition < Ductwork::Record
|
|
5
|
+
belongs_to :branch, class_name: "Ductwork::Branch"
|
|
6
|
+
belongs_to :in_step, class_name: "Ductwork::Step"
|
|
7
|
+
belongs_to :out_step, class_name: "Ductwork::Step", optional: true
|
|
8
|
+
has_many :advancements,
|
|
9
|
+
class_name: "Ductwork::Advancement",
|
|
10
|
+
foreign_key: "transition_id",
|
|
11
|
+
dependent: :destroy
|
|
12
|
+
|
|
13
|
+
validates :started_at, presence: true
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -2,12 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
class Tuple < Ductwork::Record
|
|
5
|
-
belongs_to :
|
|
5
|
+
belongs_to :run, class_name: "Ductwork::Run"
|
|
6
6
|
|
|
7
|
-
# NOTE: These model-level validations are here for symmetry. The current
|
|
8
|
-
# methods used to create `Tuple` records skip validations lol
|
|
9
7
|
validates :key, presence: true
|
|
10
|
-
validates :key, uniqueness: { scope: :pipeline_id }
|
|
11
8
|
validates :first_set_at, presence: true
|
|
12
9
|
validates :last_set_at, presence: true
|
|
13
10
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
class OptimisticLockingExecutionClaim
|
|
5
|
+
def initialize(klass, owner_process_id)
|
|
6
|
+
@id = nil
|
|
7
|
+
@execution = nil
|
|
8
|
+
@klass = klass
|
|
9
|
+
@process_id = owner_process_id
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def latest
|
|
13
|
+
Ductwork::Record.transaction do # rubocop:todo Metrics/BlockLength
|
|
14
|
+
@id = latest_availability_id
|
|
15
|
+
|
|
16
|
+
if id.present?
|
|
17
|
+
rows_updated = claim_availability
|
|
18
|
+
|
|
19
|
+
if rows_updated == 1
|
|
20
|
+
Ductwork.logger.debug(
|
|
21
|
+
msg: "Job claimed",
|
|
22
|
+
role: :job_worker,
|
|
23
|
+
process_id: process_id,
|
|
24
|
+
availability_id: id
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@execution = find_execution
|
|
28
|
+
execution.update_columns(process_id:)
|
|
29
|
+
|
|
30
|
+
update_state
|
|
31
|
+
else
|
|
32
|
+
Ductwork.logger.debug(
|
|
33
|
+
msg: "Did not claim job, avoided race condition",
|
|
34
|
+
role: :job_worker,
|
|
35
|
+
process_id: process_id,
|
|
36
|
+
availability_id: id
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
Ductwork.logger.debug(
|
|
41
|
+
msg: "No available job to claim",
|
|
42
|
+
role: :job_worker,
|
|
43
|
+
process_id: process_id,
|
|
44
|
+
pipeline: klass
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
execution
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :id, :execution, :klass, :process_id
|
|
55
|
+
|
|
56
|
+
def latest_availability_id
|
|
57
|
+
sql = Ductwork::DatabaseClock.now_sql("ductwork_availabilities.started_at")
|
|
58
|
+
|
|
59
|
+
Ductwork::Availability
|
|
60
|
+
.where(sql)
|
|
61
|
+
.where(completed_at: nil, pipeline_klass: klass)
|
|
62
|
+
.order(:started_at)
|
|
63
|
+
.limit(1)
|
|
64
|
+
.pluck(:id)
|
|
65
|
+
.first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def claim_availability
|
|
69
|
+
Ductwork::Availability
|
|
70
|
+
.where(id: id, completed_at: nil)
|
|
71
|
+
.update_all(completed_at: Time.current, process_id: process_id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def find_execution
|
|
75
|
+
Ductwork::Execution
|
|
76
|
+
.joins(:availability)
|
|
77
|
+
.find_by!(ductwork_availabilities: { id:, process_id: })
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
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)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
module Processes
|
|
5
|
+
class HealthCheck
|
|
6
|
+
def self.run(**)
|
|
7
|
+
new(**).run
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(pid: nil, verbose: false)
|
|
11
|
+
@pid = pid
|
|
12
|
+
@machine_identifier = Ductwork::MachineIdentifier.fetch
|
|
13
|
+
@verbose = verbose
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
exit_code = supervisor_processes.reduce(0) do |acc, process|
|
|
18
|
+
if process.healthy?
|
|
19
|
+
puts_healthy(process)
|
|
20
|
+
acc
|
|
21
|
+
else
|
|
22
|
+
puts_unhealthy(process)
|
|
23
|
+
acc + 1
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if pid.present? && supervisor_processes.none?
|
|
28
|
+
puts_dead
|
|
29
|
+
exit_code = 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
exit(exit_code)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :pid, :machine_identifier, :verbose
|
|
38
|
+
|
|
39
|
+
def supervisor_processes
|
|
40
|
+
Ductwork::Process.supervisor.then do |relation|
|
|
41
|
+
if pid.present?
|
|
42
|
+
relation.where(pid:, machine_identifier:)
|
|
43
|
+
else
|
|
44
|
+
relation
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def puts_dead
|
|
50
|
+
if verbose
|
|
51
|
+
puts <<~STDOUT
|
|
52
|
+
PID #{pid} (#{machine_identifier})
|
|
53
|
+
Status: dead
|
|
54
|
+
STDOUT
|
|
55
|
+
else
|
|
56
|
+
puts "dead"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def puts_healthy(process)
|
|
61
|
+
if verbose
|
|
62
|
+
puts_verbose(process, "healthy")
|
|
63
|
+
else
|
|
64
|
+
puts "healthy"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def puts_unhealthy(process)
|
|
69
|
+
if verbose
|
|
70
|
+
puts_verbose(process, "unhealthy")
|
|
71
|
+
else
|
|
72
|
+
puts "unhealthy"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def puts_verbose(process, status)
|
|
77
|
+
puts <<~STDOUT
|
|
78
|
+
PID #{process.pid} (#{process.machine_identifier})
|
|
79
|
+
ID: #{process.id}
|
|
80
|
+
Created At: #{process.created_at.iso8601}
|
|
81
|
+
Last Heartbeat At: #{process.last_heartbeat_at.iso8601}
|
|
82
|
+
Status: #{status}
|
|
83
|
+
STDOUT
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
module Processes
|
|
5
5
|
class JobWorker
|
|
6
|
-
attr_reader :thread, :last_heartbeat_at, :
|
|
6
|
+
attr_reader :thread, :last_heartbeat_at, :execution, :pipeline
|
|
7
7
|
|
|
8
8
|
def initialize(pipeline, id)
|
|
9
9
|
@pipeline = pipeline
|
|
@@ -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
|
+
execution.nil? && last_heartbeat_too_long_ago?
|
|
32
|
+
end
|
|
33
|
+
|
|
27
34
|
def stop
|
|
28
35
|
running_context.shutdown!
|
|
29
36
|
end
|
|
@@ -45,7 +52,7 @@ module Ductwork
|
|
|
45
52
|
|
|
46
53
|
attr_reader :id, :running_context
|
|
47
54
|
|
|
48
|
-
def work_loop
|
|
55
|
+
def work_loop # rubocop:todo Metrics
|
|
49
56
|
run_hooks_for(:start)
|
|
50
57
|
|
|
51
58
|
Ductwork.logger.debug(
|
|
@@ -55,29 +62,74 @@ module Ductwork
|
|
|
55
62
|
)
|
|
56
63
|
|
|
57
64
|
while running_context.running?
|
|
58
|
-
|
|
59
|
-
msg: "Attempting to claim job",
|
|
60
|
-
role: :job_worker,
|
|
61
|
-
pipeline: pipeline
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
@job = Ductwork.wrap_with_app_executor do
|
|
65
|
-
Job.claim_latest(pipeline)
|
|
66
|
-
end
|
|
65
|
+
owner_process_id = nil
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
Ductwork.
|
|
70
|
-
job
|
|
67
|
+
begin
|
|
68
|
+
Ductwork.logger.debug(
|
|
69
|
+
msg: "Attempting to claim job",
|
|
70
|
+
role: :job_worker,
|
|
71
|
+
pipeline: pipeline
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@execution = Ductwork.wrap_with_app_executor do
|
|
75
|
+
owner_process_id = Ductwork::Process.current.id
|
|
76
|
+
Ductwork::ExecutionClaim.new(pipeline, owner_process_id).latest
|
|
71
77
|
end
|
|
72
78
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
if execution.present?
|
|
80
|
+
Ductwork::FaultInjection.checkpoint(:after_job_claim)
|
|
81
|
+
|
|
82
|
+
Ductwork.wrap_with_app_executor do
|
|
83
|
+
execution.call(pipeline, owner_process_id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@execution = nil
|
|
87
|
+
else
|
|
88
|
+
Ductwork.logger.debug(
|
|
89
|
+
msg: "No job to claim, looping",
|
|
90
|
+
role: :job_worker,
|
|
91
|
+
pipeline: pipeline
|
|
92
|
+
)
|
|
93
|
+
sleep(polling_timeout)
|
|
94
|
+
end
|
|
95
|
+
rescue Ductwork::Execution::CommitFailed => e
|
|
96
|
+
Ductwork.logger.error(
|
|
97
|
+
msg: "Execution commit failed",
|
|
98
|
+
error_klass: e.class.name,
|
|
99
|
+
error_message: e.message,
|
|
100
|
+
job_id: execution&.job_id,
|
|
101
|
+
job_klass: execution&.job&.klass,
|
|
77
102
|
role: :job_worker,
|
|
78
103
|
pipeline: pipeline
|
|
79
104
|
)
|
|
80
|
-
|
|
105
|
+
|
|
106
|
+
@execution = nil
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
if execution.present?
|
|
109
|
+
Ductwork.wrap_with_app_executor do
|
|
110
|
+
execution.crashed!
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Ductwork.logger.error(
|
|
115
|
+
msg: "Unexpected error in work loop",
|
|
116
|
+
error_klass: e.class.name,
|
|
117
|
+
error_message: e.message,
|
|
118
|
+
job_id: execution&.job_id,
|
|
119
|
+
job_klass: execution&.job&.klass,
|
|
120
|
+
role: :job_worker,
|
|
121
|
+
pipeline: pipeline
|
|
122
|
+
)
|
|
123
|
+
ensure
|
|
124
|
+
if execution.present? && execution.reload.completed_at.nil?
|
|
125
|
+
Ductwork.wrap_with_app_executor do
|
|
126
|
+
execution.crashed!
|
|
127
|
+
rescue StandardError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
@execution = nil
|
|
81
133
|
end
|
|
82
134
|
|
|
83
135
|
@last_heartbeat_at = Time.current
|
|
@@ -92,6 +144,20 @@ module Ductwork
|
|
|
92
144
|
run_hooks_for(:stop)
|
|
93
145
|
end
|
|
94
146
|
|
|
147
|
+
def cleanup_dead_thread!
|
|
148
|
+
if execution.present? && execution.reload.completed_at.nil?
|
|
149
|
+
execution.crashed!
|
|
150
|
+
end
|
|
151
|
+
ensure
|
|
152
|
+
@execution = nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def last_heartbeat_too_long_ago?
|
|
156
|
+
threshold = Ductwork.configuration.supervisor_reaper_timeout
|
|
157
|
+
|
|
158
|
+
(Time.current - last_heartbeat_at) > threshold
|
|
159
|
+
end
|
|
160
|
+
|
|
95
161
|
def run_hooks_for(event)
|
|
96
162
|
Ductwork.hooks[:worker].fetch(event, []).each do |block|
|
|
97
163
|
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_job_workers
|
|
29
29
|
|
|
30
30
|
Ductwork.logger.debug(
|
|
@@ -36,7 +36,7 @@ module Ductwork
|
|
|
36
36
|
while 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
|
|
|
@@ -47,13 +47,9 @@ module Ductwork
|
|
|
47
47
|
|
|
48
48
|
attr_reader :pipelines, :running_context, :job_workers
|
|
49
49
|
|
|
50
|
-
def
|
|
50
|
+
def adopt_or_create_process!
|
|
51
51
|
Ductwork.wrap_with_app_executor do
|
|
52
|
-
Ductwork::Process.
|
|
53
|
-
pid: ::Process.pid,
|
|
54
|
-
machine_identifier: Ductwork::MachineIdentifier.fetch,
|
|
55
|
-
last_heartbeat_at: Time.current
|
|
56
|
-
)
|
|
52
|
+
Ductwork::Process.adopt_or_create_current!(:job_worker)
|
|
57
53
|
end
|
|
58
54
|
end
|
|
59
55
|
|
|
@@ -78,37 +74,32 @@ module Ductwork
|
|
|
78
74
|
running_context.running?
|
|
79
75
|
end
|
|
80
76
|
|
|
81
|
-
def
|
|
82
|
-
Ductwork
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
Ductwork.logger.warn(
|
|
92
|
-
msg: "Restarted job worker",
|
|
93
|
-
role: :job_worker_runner,
|
|
94
|
-
pipeline: job_worker.pipeline,
|
|
95
|
-
thread: job_worker.name
|
|
96
|
-
)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
Ductwork.logger.debug(
|
|
100
|
-
msg: "Checked thread health",
|
|
101
|
-
role: :job_worker_runner,
|
|
102
|
-
pipelines: pipelines
|
|
77
|
+
def check_workers_health
|
|
78
|
+
Ductwork::Processes::WorkerHealthCheck
|
|
79
|
+
.new(job_workers, :job_worker_runner)
|
|
80
|
+
.check
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
Ductwork.logger.warn(
|
|
83
|
+
msg: "Checking worker health failed",
|
|
84
|
+
error_klass: e.class.to_s,
|
|
85
|
+
error_message: e.message,
|
|
86
|
+
role: :job_worker_runner
|
|
103
87
|
)
|
|
104
88
|
end
|
|
105
89
|
|
|
106
90
|
def report_heartbeat!
|
|
107
91
|
Ductwork.logger.debug(msg: "Reporting heartbeat", role: :job_worker_runner)
|
|
108
92
|
Ductwork.wrap_with_app_executor do
|
|
109
|
-
Ductwork::Process.report_heartbeat!
|
|
93
|
+
Ductwork::Process.report_heartbeat!(:job_worker)
|
|
110
94
|
end
|
|
111
95
|
Ductwork.logger.debug(msg: "Reported heartbeat", role: :job_worker_runner)
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
Ductwork.logger.warn(
|
|
98
|
+
msg: "Reporting heartbeat failed",
|
|
99
|
+
error_klass: e.class.to_s,
|
|
100
|
+
error_message: e.message,
|
|
101
|
+
role: :job_worker_runner
|
|
102
|
+
)
|
|
112
103
|
end
|
|
113
104
|
|
|
114
105
|
def shutdown!
|
|
@@ -116,7 +107,7 @@ module Ductwork
|
|
|
116
107
|
job_workers.each(&:stop)
|
|
117
108
|
await_threads_graceful_shutdown
|
|
118
109
|
kill_remaining_job_workers
|
|
119
|
-
|
|
110
|
+
reap_process_record!
|
|
120
111
|
end
|
|
121
112
|
|
|
122
113
|
def await_threads_graceful_shutdown
|
|
@@ -152,12 +143,9 @@ module Ductwork
|
|
|
152
143
|
end
|
|
153
144
|
end
|
|
154
145
|
|
|
155
|
-
def
|
|
146
|
+
def reap_process_record!
|
|
156
147
|
Ductwork.wrap_with_app_executor do
|
|
157
|
-
Ductwork::Process.
|
|
158
|
-
pid: ::Process.pid,
|
|
159
|
-
machine_identifier: Ductwork::MachineIdentifier.fetch
|
|
160
|
-
).delete
|
|
148
|
+
Ductwork::Process.current&.reap!(:job_worker_runner, force: true)
|
|
161
149
|
end
|
|
162
150
|
end
|
|
163
151
|
end
|