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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/audit-clock-drift/SKILL.md +11 -0
  3. data/.claude/skills/audit-database-indexes/SKILL.md +11 -0
  4. data/.claude/skills/audit-database-support/SKILL.md +18 -0
  5. data/.claude/skills/audit-durability/SKILL.md +17 -0
  6. data/.saturnci/database.yml +1 -0
  7. data/CHANGELOG-PRO.md +60 -0
  8. data/CHANGELOG.md +144 -1
  9. data/CLAUDE.md +34 -0
  10. data/README.md +24 -14
  11. data/app/controllers/ductwork/application_controller.rb +3 -3
  12. data/app/controllers/ductwork/dashboards_controller.rb +1 -1
  13. data/app/controllers/ductwork/pipelines_controller.rb +4 -3
  14. data/app/helpers/ductwork/application_helper.rb +2 -2
  15. data/app/views/ductwork/dashboards/show.html.erb +12 -12
  16. data/app/views/ductwork/pipelines/index.html.erb +12 -12
  17. data/app/views/ductwork/pipelines/show.html.erb +13 -13
  18. data/app/views/ductwork/step_errors/index.html.erb +2 -2
  19. data/lib/ductwork/branch_claim.rb +185 -0
  20. data/lib/ductwork/cli.rb +73 -24
  21. data/lib/ductwork/configuration.rb +73 -3
  22. data/lib/ductwork/context.rb +17 -19
  23. data/lib/ductwork/database_clock.rb +84 -0
  24. data/lib/ductwork/dsl/branch_builder.rb +6 -1
  25. data/lib/ductwork/dsl/definition_builder.rb +24 -1
  26. data/lib/ductwork/{job_claim.rb → execution_claim.rb} +7 -6
  27. data/lib/ductwork/fault_injection.rb +35 -0
  28. data/lib/ductwork/migration_helper.rb +1 -1
  29. data/lib/ductwork/models/advancement.rb +48 -0
  30. data/lib/ductwork/models/attempt.rb +9 -0
  31. data/lib/ductwork/models/availability.rb +2 -0
  32. data/lib/ductwork/models/branch.rb +800 -0
  33. data/lib/ductwork/models/branch_link.rb +10 -0
  34. data/lib/ductwork/models/execution.rb +199 -2
  35. data/lib/ductwork/models/job.rb +8 -121
  36. data/lib/ductwork/models/pipeline.rb +150 -272
  37. data/lib/ductwork/models/process.rb +177 -5
  38. data/lib/ductwork/models/result.rb +2 -1
  39. data/lib/ductwork/models/run.rb +119 -1
  40. data/lib/ductwork/models/step.rb +26 -6
  41. data/lib/ductwork/models/transition.rb +15 -0
  42. data/lib/ductwork/models/tuple.rb +1 -4
  43. data/lib/ductwork/models/workflow.rb +3 -0
  44. data/lib/ductwork/optimistic_locking_execution_claim.rb +97 -0
  45. data/lib/ductwork/processes/health_check.rb +87 -0
  46. data/lib/ductwork/processes/job_worker.rb +86 -20
  47. data/lib/ductwork/processes/job_worker_runner.rb +25 -37
  48. data/lib/ductwork/processes/pipeline_advancer.rb +48 -76
  49. data/lib/ductwork/processes/pipeline_advancer_runner.rb +34 -44
  50. data/lib/ductwork/processes/process_supervisor.rb +87 -10
  51. data/lib/ductwork/processes/thread_supervisor.rb +55 -21
  52. data/lib/ductwork/processes/thread_supervisor_runner.rb +8 -5
  53. data/lib/ductwork/processes/worker_health_check.rb +55 -0
  54. data/lib/ductwork/row_locking_execution_claim.rb +77 -0
  55. data/lib/ductwork/testing/rspec.rb +28 -11
  56. data/lib/ductwork/version.rb +1 -1
  57. data/lib/ductwork.rb +5 -0
  58. data/lib/generators/ductwork/install/install_generator.rb +14 -4
  59. data/lib/generators/ductwork/install/templates/config/ductwork.yml +4 -0
  60. data/lib/generators/ductwork/install/templates/db/create_ductwork_advancements.rb +29 -0
  61. data/lib/generators/ductwork/install/templates/db/create_ductwork_attempts.rb +20 -0
  62. data/lib/generators/ductwork/install/templates/db/create_ductwork_availabilities.rb +18 -5
  63. data/lib/generators/ductwork/install/templates/db/create_ductwork_branch_links.rb +27 -0
  64. data/lib/generators/ductwork/install/templates/db/create_ductwork_branches.rb +37 -0
  65. data/lib/generators/ductwork/install/templates/db/create_ductwork_executions.rb +8 -1
  66. data/lib/generators/ductwork/install/templates/db/create_ductwork_pipelines.rb +0 -8
  67. data/lib/generators/ductwork/install/templates/db/create_ductwork_processes.rb +2 -0
  68. data/lib/generators/ductwork/install/templates/db/create_ductwork_results.rb +1 -0
  69. data/lib/generators/ductwork/install/templates/db/create_ductwork_runs.rb +45 -6
  70. data/lib/generators/ductwork/install/templates/db/create_ductwork_steps.rb +20 -5
  71. data/lib/generators/ductwork/install/templates/db/create_ductwork_transitions.rb +43 -0
  72. data/lib/generators/ductwork/install/templates/db/create_ductwork_tuples.rb +3 -3
  73. data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_advancements.rb +7 -0
  74. data/lib/generators/ductwork/update/templates/db/add_crash_count_to_ductwork_executions.rb +14 -0
  75. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_results.rb +7 -0
  76. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_runs.rb +9 -0
  77. data/lib/generators/ductwork/update/templates/db/add_indexes_to_ductwork_transitions.rb +16 -0
  78. data/lib/generators/ductwork/update/templates/db/add_pipeline_started_index_to_ductwork_runs.rb +7 -0
  79. data/lib/generators/ductwork/update/templates/db/add_process_id_to_ductwork_executions.rb +35 -0
  80. data/lib/generators/ductwork/update/templates/db/add_role_to_ductwork_processes.rb +20 -0
  81. data/lib/generators/ductwork/update/templates/db/associate_branches_to_runs.rb +74 -0
  82. data/lib/generators/ductwork/update/templates/db/associate_steps_to_branches.rb +24 -0
  83. data/lib/generators/ductwork/update/templates/db/associate_steps_to_runs.rb +72 -0
  84. data/lib/generators/ductwork/update/templates/db/associate_tuples_to_runs.rb +73 -0
  85. data/lib/generators/ductwork/update/templates/db/backfill_branch_ids_on_steps.rb +21 -0
  86. data/lib/generators/ductwork/update/templates/db/create_ductwork_advancements.rb +28 -0
  87. data/lib/generators/ductwork/update/templates/db/create_ductwork_branch_links.rb +27 -0
  88. data/lib/generators/ductwork/update/templates/db/create_ductwork_branches.rb +37 -0
  89. data/lib/generators/ductwork/update/templates/db/create_ductwork_runs.rb +55 -0
  90. data/lib/generators/ductwork/update/templates/db/create_ductwork_transitions.rb +32 -0
  91. data/lib/generators/ductwork/update/templates/db/denormalize_pipeline_klass_on_availabilities.rb +11 -4
  92. data/lib/generators/ductwork/update/templates/db/migrate_tables_to_uuid_primary_key.rb +5 -0
  93. data/lib/generators/ductwork/update/templates/db/rename_runs_to_attempts.rb +7 -0
  94. data/lib/generators/ductwork/update/templates/db/update_process_associations.rb +29 -0
  95. data/lib/generators/ductwork/update/update_generator.rb +99 -0
  96. metadata +47 -6
  97. data/lib/ductwork/optimistic_locking_job_claim.rb +0 -88
  98. data/lib/ductwork/row_locking_job_claim.rb +0 -75
@@ -2,18 +2,190 @@
2
2
 
3
3
  module Ductwork
4
4
  class Process < Ductwork::Record
5
- class NotFoundError < StandardError; end
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
- def self.report_heartbeat!
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
- find_by!(pid:, machine_identifier:)
14
- .update!(last_heartbeat_at: Time.current)
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
- raise NotFoundError, "Process #{pid} not found"
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
@@ -9,6 +9,7 @@ module Ductwork
9
9
  enum :result_type,
10
10
  success: "success",
11
11
  failure: "failure",
12
- timed_out: "timed_out"
12
+ timed_out: "timed_out",
13
+ process_crashed: "process_crashed"
13
14
  end
14
15
  end
@@ -2,8 +2,126 @@
2
2
 
3
3
  module Ductwork
4
4
  class Run < Ductwork::Record
5
- belongs_to :execution, class_name: "Ductwork::Execution"
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
@@ -2,8 +2,13 @@
2
2
 
3
3
  module Ductwork
4
4
  class Step < Ductwork::Record
5
- belongs_to :pipeline, class_name: "Ductwork::Pipeline"
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(pipeline_id, *, **)
37
+ def self.build_for_execution(run_id, *, **)
33
38
  instance = allocate
34
- instance.instance_variable_set(:@pipeline_id, pipeline_id)
39
+ instance.instance_variable_set(:@run_id, run_id)
35
40
  instance.send(:initialize, *, **)
36
41
  instance
37
42
  end
38
43
 
39
- def pipeline_id
40
- @pipeline_id || (@attributes && super)
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(pipeline_id)
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 :pipeline, class_name: "Ductwork::Pipeline"
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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ Ductwork::Workflow = Ductwork::Pipeline
@@ -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