ductwork 0.26.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 +60 -0
- data/CHANGELOG.md +144 -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 +6 -1
- data/lib/ductwork/dsl/definition_builder.rb +24 -1
- 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 +150 -272
- 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
|
@@ -2,18 +2,190 @@
|
|
|
2
2
|
|
|
3
3
|
module Ductwork
|
|
4
4
|
class Process < Ductwork::Record
|
|
5
|
-
|
|
5
|
+
has_many :advancements,
|
|
6
|
+
class_name: "Ductwork::Advancement",
|
|
7
|
+
foreign_key: "process_id",
|
|
8
|
+
dependent: :nullify
|
|
9
|
+
has_many :availabilities,
|
|
10
|
+
class_name: "Ductwork::Availability",
|
|
11
|
+
foreign_key: "process_id",
|
|
12
|
+
dependent: :nullify
|
|
13
|
+
has_many :executions,
|
|
14
|
+
class_name: "Ductwork::Execution",
|
|
15
|
+
foreign_key: "process_id",
|
|
16
|
+
dependent: :nullify
|
|
6
17
|
|
|
7
18
|
validates :pid, uniqueness: { scope: :machine_identifier }
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
enum :role,
|
|
21
|
+
supervisor: "supervisor",
|
|
22
|
+
pipeline_advancer: "pipeline_advancer",
|
|
23
|
+
job_worker: "job_worker"
|
|
24
|
+
|
|
25
|
+
ORPHANED_CLAIM_SWEEP_MULTIPLIER = 3
|
|
26
|
+
|
|
27
|
+
def self.adopt_or_create_current!(role)
|
|
10
28
|
pid = ::Process.pid
|
|
11
29
|
machine_identifier = Ductwork::MachineIdentifier.fetch
|
|
30
|
+
last_heartbeat_at = Ductwork::DatabaseClock.now
|
|
31
|
+
existing = Ductwork::Process.find_by(pid:, machine_identifier:)
|
|
32
|
+
|
|
33
|
+
# NOTE: Same pid + machine_identifier can only mean the OS reused this
|
|
34
|
+
# pid, which happens after the prior process at this identity has
|
|
35
|
+
# exited -- a live process can never be replaced while still running.
|
|
36
|
+
# So its in-flight claims belong to a dead incarnation and must always
|
|
37
|
+
# be recovered here, even when the heartbeat still looks fresh because
|
|
38
|
+
# the reaper's timeout hasn't elapsed yet.
|
|
39
|
+
existing&.recover_crashed_claims!(role)
|
|
40
|
+
|
|
41
|
+
Ductwork::Process
|
|
42
|
+
.find_or_initialize_by(pid:, machine_identifier:)
|
|
43
|
+
.tap { |process| process.update!(last_heartbeat_at:, role:) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.current
|
|
47
|
+
pid = ::Process.pid
|
|
48
|
+
machine_identifier = Ductwork::MachineIdentifier.fetch
|
|
49
|
+
|
|
50
|
+
find_by(pid:, machine_identifier:)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.reap_all!(role)
|
|
54
|
+
count = 0
|
|
55
|
+
timeout = Ductwork.configuration.supervisor_reaper_timeout
|
|
56
|
+
sql = Ductwork::DatabaseClock.ago_sql("last_heartbeat_at", timeout)
|
|
57
|
+
|
|
58
|
+
Ductwork.logger.debug(
|
|
59
|
+
msg: "Reaping orphaned process records",
|
|
60
|
+
role: role
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
where(sql).find_each do |process|
|
|
64
|
+
process.reap!(role)
|
|
65
|
+
count += 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Ductwork.logger.debug(
|
|
69
|
+
msg: "Reaped #{count} orphaned process records",
|
|
70
|
+
count: count,
|
|
71
|
+
role: role
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# NOTE: backstop for claims that lost their process_id (dependent: :nullify
|
|
76
|
+
# on a process destroy racing a fresh claim) and so are unreachable through
|
|
77
|
+
# any Process association -- `reap!`/`reap_all!` above only ever look
|
|
78
|
+
# through a Process record's own advancements/executions, and a nil
|
|
79
|
+
# process_id means there is no Process record to look through. This scans
|
|
80
|
+
# the orphaned rows directly instead.
|
|
81
|
+
#
|
|
82
|
+
# Executions are additionally scoped to availabilities with a completed_at
|
|
83
|
+
# present: an unclaimed execution legitimately has process_id: nil while
|
|
84
|
+
# it waits to be picked up (see RowLockingExecutionClaim#claim_availability,
|
|
85
|
+
# which only sets process_id at claim time), so process_id: nil alone
|
|
86
|
+
# would misidentify perfectly healthy queued work as orphaned.
|
|
87
|
+
def self.sweep_orphaned_claims!(role)
|
|
88
|
+
timeout = Ductwork.configuration.supervisor_reaper_timeout * ORPHANED_CLAIM_SWEEP_MULTIPLIER
|
|
89
|
+
count = 0
|
|
90
|
+
advancement_sql = Ductwork::DatabaseClock.ago_sql("started_at", timeout)
|
|
91
|
+
|
|
92
|
+
Ductwork::Advancement
|
|
93
|
+
.where(process_id: nil, completed_at: nil)
|
|
94
|
+
.where(advancement_sql)
|
|
95
|
+
.find_each do |advancement|
|
|
96
|
+
advancement.process_crashed!
|
|
97
|
+
count += 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
execution_sql = Ductwork::DatabaseClock.ago_sql("ductwork_availabilities.completed_at", timeout)
|
|
12
101
|
|
|
13
|
-
|
|
14
|
-
.
|
|
102
|
+
Ductwork::Execution
|
|
103
|
+
.joins(:availability)
|
|
104
|
+
.where(process_id: nil, completed_at: nil)
|
|
105
|
+
.where.not(ductwork_availabilities: { completed_at: nil })
|
|
106
|
+
.where(execution_sql)
|
|
107
|
+
.find_each do |execution|
|
|
108
|
+
execution.crashed!
|
|
109
|
+
count += 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
Ductwork.logger.debug(
|
|
113
|
+
msg: "Swept #{count} orphaned claims",
|
|
114
|
+
count: count,
|
|
115
|
+
role: role
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.report_heartbeat!(role)
|
|
120
|
+
process = current
|
|
121
|
+
|
|
122
|
+
if process.present?
|
|
123
|
+
process.update!(last_heartbeat_at: Ductwork::DatabaseClock.now)
|
|
124
|
+
process
|
|
125
|
+
else
|
|
126
|
+
Ductwork.logger.warn(
|
|
127
|
+
msg: "Process record missing, re-adopting (likely reaped after host suspend)",
|
|
128
|
+
pid: ::Process.pid
|
|
129
|
+
)
|
|
130
|
+
adopt_or_create_current!(role)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def reap!(role, force: false)
|
|
135
|
+
timeout = Ductwork.configuration.supervisor_reaper_timeout
|
|
136
|
+
sql = Ductwork::DatabaseClock.ago_sql("last_heartbeat_at", timeout)
|
|
137
|
+
|
|
138
|
+
Ductwork.logger.debug(
|
|
139
|
+
msg: "Reaping orphaned process record #{id}",
|
|
140
|
+
id: id,
|
|
141
|
+
role: role
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
Ductwork::Record.transaction do
|
|
145
|
+
# NOTE: Callers that have already killed/stopped the process hold proof
|
|
146
|
+
# of death and pass force: true to skip the staleness guard. The row
|
|
147
|
+
# lock and existence check are kept either way to stay atomic and to
|
|
148
|
+
# avoid double-reaping a record another parent already cleaned up
|
|
149
|
+
scope = Ductwork::Process.where(id:).lock
|
|
150
|
+
scope = scope.where(sql) unless force
|
|
151
|
+
|
|
152
|
+
return unless scope.exists?
|
|
153
|
+
|
|
154
|
+
recover_crashed_claims!(role)
|
|
155
|
+
destroy
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Ductwork.logger.debug(
|
|
159
|
+
msg: "Reaped orphaned process record #{id}",
|
|
160
|
+
id: id,
|
|
161
|
+
role: role
|
|
162
|
+
)
|
|
15
163
|
rescue ActiveRecord::RecordNotFound
|
|
16
|
-
|
|
164
|
+
Ductwork.logger.debug(
|
|
165
|
+
msg: "Process already reaped by another parent",
|
|
166
|
+
id: id,
|
|
167
|
+
role: role
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def recover_crashed_claims!(role)
|
|
172
|
+
Ductwork.logger.debug(
|
|
173
|
+
msg: "Recovering in-flight claims on reused process record #{id}",
|
|
174
|
+
id: id,
|
|
175
|
+
role: role
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
Ductwork::Record.transaction do
|
|
179
|
+
advancements.where(completed_at: nil).find_each(&:process_crashed!)
|
|
180
|
+
executions.where(completed_at: nil).find_each(&:crashed!)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def healthy?
|
|
185
|
+
timeout = Ductwork.configuration.supervisor_reaper_timeout
|
|
186
|
+
sql = Ductwork::DatabaseClock.ago_sql("last_heartbeat_at", timeout)
|
|
187
|
+
|
|
188
|
+
self.class.where(id:).where(sql).none?
|
|
17
189
|
end
|
|
18
190
|
end
|
|
19
191
|
end
|
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
|