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.
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 +72 -0
  8. data/CHANGELOG.md +148 -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 +46 -1
  25. data/lib/ductwork/dsl/definition_builder.rb +101 -2
  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 +152 -255
  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
@@ -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
@@ -3,7 +3,7 @@
3
3
  module Ductwork
4
4
  module Processes
5
5
  class JobWorker
6
- attr_reader :thread, :last_heartbeat_at, :job, :pipeline
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
- alias restart start
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
- Ductwork.logger.debug(
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
- if job.present?
69
- Ductwork.wrap_with_app_executor do
70
- job.execute(pipeline)
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
- @job = nil
74
- else
75
- Ductwork.logger.debug(
76
- msg: "No job to claim, looping",
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
- sleep(polling_timeout)
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
- create_process_record!
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
- check_thread_health
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 create_process_record!
50
+ def adopt_or_create_process!
51
51
  Ductwork.wrap_with_app_executor do
52
- Ductwork::Process.create!(
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 check_thread_health
82
- Ductwork.logger.debug(
83
- msg: "Checking thread health",
84
- role: :job_worker_runner,
85
- pipelines: pipelines
86
- )
87
- job_workers.each do |job_worker|
88
- if !job_worker.alive?
89
- job_worker.restart
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
- delete_process_record!
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 delete_process_record!
146
+ def reap_process_record!
156
147
  Ductwork.wrap_with_app_executor do
157
- Ductwork::Process.find_by!(
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