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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class BranchLink < Ductwork::Record
5
+ belongs_to :parent_branch, class_name: "Ductwork::Branch"
6
+ belongs_to :child_branch, class_name: "Ductwork::Branch"
7
+
8
+ validates :parent_branch_id, uniqueness: { scope: :child_branch_id }
9
+ end
10
+ end
@@ -1,13 +1,210 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ductwork
4
- class Execution < Ductwork::Record
4
+ class Execution < Ductwork::Record # rubocop:todo Metrics/ClassLength
5
5
  belongs_to :job, class_name: "Ductwork::Job"
6
+ belongs_to :process, class_name: "Ductwork::Process", optional: true
6
7
  has_one :availability, class_name: "Ductwork::Availability", foreign_key: "execution_id", dependent: :destroy
7
- has_one :run, class_name: "Ductwork::Run", foreign_key: "execution_id", dependent: :destroy
8
+ has_one :attempt, class_name: "Ductwork::Attempt", foreign_key: "execution_id", dependent: :destroy
8
9
  has_one :result, class_name: "Ductwork::Result", foreign_key: "execution_id", dependent: :destroy
9
10
 
10
11
  validates :retry_count, presence: true
11
12
  validates :started_at, presence: true
13
+
14
+ FAILED_EXECUTION_TIMEOUT = 10.seconds
15
+
16
+ class CommitFailed < StandardError; end
17
+
18
+ def call(pipeline, owner_process_id)
19
+ Ductwork.logger.debug(
20
+ msg: "Executing job",
21
+ role: :job_worker,
22
+ pipeline: pipeline,
23
+ job_klass: job.klass
24
+ )
25
+ args = JSON.parse(job.input_args)["args"]
26
+ instance = Object.const_get(job.klass).build_for_execution(job.step.run_id, *args)
27
+ create_attempt!(started_at: Time.current)
28
+ output_payload = nil
29
+
30
+ begin
31
+ output_payload = instance.execute
32
+ Ductwork::FaultInjection.checkpoint(:during_job_execution)
33
+ rescue StandardError => e
34
+ errored!(e, owner_process_id)
35
+ log_job_executed(pipeline, "error")
36
+
37
+ return
38
+ end
39
+
40
+ # AT-LEAST-ONCE CONTRACT: `instance.execute` has already run and any
41
+ # side effects it performed are now durable. The commit below can still
42
+ # fail (CommitFailed) if the reaper clobbered this claim, in which case
43
+ # `crashed!` creates a fresh availability and the job runs AGAIN.
44
+ # Ductwork guarantees at-least-once, never exactly-once, execution for
45
+ # the forking worker model: a job body may be re-run after a successful
46
+ # side effect. Jobs with non-idempotent effects MUST guard them at the
47
+ # application layer (use `Step#idempotency_key`). The reuse window is
48
+ # widened by transient stale claims; narrowing that is a separate fix,
49
+ # but it cannot eliminate this contract.
50
+ succeeded!(output_payload, owner_process_id)
51
+ log_job_executed(pipeline, "succeeded")
52
+ end
53
+
54
+ def succeeded!(output_payload, owner_process_id)
55
+ completed_at = Time.current
56
+ payload = JSON.dump({ payload: output_payload })
57
+
58
+ Ductwork::Record.transaction do
59
+ rows_updated = Ductwork::Execution
60
+ .where(id: id, completed_at: nil, process_id: owner_process_id)
61
+ .update_all(completed_at:)
62
+
63
+ if rows_updated.zero?
64
+ raise Ductwork::Execution::CommitFailed, "Reaper clobbered claimed job execution"
65
+ end
66
+
67
+ job.update!(output_payload: payload, completed_at: Time.current)
68
+ attempt.update!(completed_at: Time.current)
69
+ create_result!(result_type: "success")
70
+ job.step.update!(status: :advancing)
71
+ end
72
+ end
73
+
74
+ def crashed! # rubocop:todo Metrics
75
+ run = job.step.run
76
+ max_crash = Ductwork.configuration.job_worker_max_crash(
77
+ pipeline: run.pipeline_klass,
78
+ step: job.klass
79
+ )
80
+
81
+ Ductwork::Record.transaction do # rubocop:todo Metrics/BlockLength
82
+ rows_updated = Ductwork::Execution
83
+ .where(id: id, process_id: process_id, completed_at: nil)
84
+ .update_all(completed_at: Time.current)
85
+
86
+ return if rows_updated.zero?
87
+
88
+ reload
89
+ attempt&.update!(completed_at: Time.current)
90
+ create_result!(result_type: "process_crashed")
91
+
92
+ if crash_count < max_crash
93
+ new_crash_count = crash_count + 1
94
+ started_at = crash_backoff_at(new_crash_count, max_crash)
95
+
96
+ new_execution = job.executions.create!(
97
+ retry_count: retry_count,
98
+ crash_count: new_crash_count,
99
+ started_at: started_at
100
+ )
101
+ new_execution.create_availability!(
102
+ started_at: started_at,
103
+ pipeline_klass: run.pipeline_klass
104
+ )
105
+ else
106
+ job.step.update!(status: :failed)
107
+
108
+ Ductwork.logger.error(
109
+ msg: "Job exceeded crash limit and failed",
110
+ job_id: job.id,
111
+ job_klass: job.klass,
112
+ run_id: run.id,
113
+ role: :job_worker
114
+ )
115
+ end
116
+ end
117
+ end
118
+
119
+ def errored!(error, owner_process_id) # rubocop:todo Metrics
120
+ run = job.step.run
121
+ completed_at = Time.current
122
+ max_retry = Ductwork.configuration.job_worker_max_retry(
123
+ pipeline: run.pipeline_klass,
124
+ step: job.klass
125
+ )
126
+
127
+ Ductwork::Record.transaction do # rubocop:todo Metrics/BlockLength
128
+ rows_updated = Ductwork::Execution
129
+ .where(id: id, completed_at: nil, process_id: owner_process_id)
130
+ .update_all(completed_at:)
131
+
132
+ if rows_updated.zero?
133
+ raise Ductwork::Execution::CommitFailed, "Reaper clobbered claimed job execution"
134
+ end
135
+
136
+ attempt.update!(completed_at: Time.current)
137
+ create_result!(
138
+ result_type: "failure",
139
+ error_klass: error.class.to_s,
140
+ error_message: error.message,
141
+ error_backtrace: error.backtrace.join("\n")
142
+ )
143
+
144
+ if retry_count < max_retry
145
+ retry_at = Ductwork::DatabaseClock.now + FAILED_EXECUTION_TIMEOUT
146
+ new_execution = job.executions.create!(
147
+ retry_count: retry_count + 1,
148
+ crash_count: crash_count,
149
+ started_at: retry_at
150
+ )
151
+
152
+ new_execution.create_availability!(
153
+ started_at: retry_at,
154
+ pipeline_klass: run.pipeline_klass
155
+ )
156
+
157
+ Ductwork.logger.warn(
158
+ msg: "Job errored",
159
+ error_klass: error.class.name,
160
+ error_message: error.message,
161
+ job_id: job.id,
162
+ job_klass: job.klass,
163
+ run_id: run.id,
164
+ role: :job_worker
165
+ )
166
+ elsif retry_count >= max_retry
167
+ job.step.update!(status: :failed)
168
+
169
+ Ductwork.logger.error(
170
+ msg: "Job exhausted retries and failed",
171
+ error_klass: error.class.name,
172
+ error_message: error.message,
173
+ job_id: job.id,
174
+ job_klass: job.klass,
175
+ run_id: run.id,
176
+ role: :job_worker
177
+ )
178
+ end
179
+ end
180
+ end
181
+
182
+ private
183
+
184
+ # NOTE: the first third (floored) of the crash budget retries immediately
185
+ # so a one-off reaper clobber / deploy restart recovers fast; past that,
186
+ # delay linearly so sub-cap crash loops stop hammering the database and
187
+ # worker pool
188
+ def crash_backoff_at(crash_count, max_crash)
189
+ immediate_threshold = max_crash / 3
190
+ now = Ductwork::DatabaseClock.now
191
+
192
+ if crash_count <= immediate_threshold
193
+ now
194
+ else
195
+ now + (crash_count * FAILED_EXECUTION_TIMEOUT)
196
+ end
197
+ end
198
+
199
+ def log_job_executed(pipeline, result_status)
200
+ Ductwork.logger.info(
201
+ msg: "Job executed",
202
+ pipeline: pipeline,
203
+ job_id: job.id,
204
+ job_klass: job.klass,
205
+ result: result_status,
206
+ role: :job_worker
207
+ )
208
+ end
12
209
  end
13
210
  end
@@ -9,25 +9,22 @@ module Ductwork
9
9
  validates :started_at, presence: true
10
10
  validates :input_args, presence: true
11
11
 
12
- FAILED_EXECUTION_TIMEOUT = 10.seconds
13
-
14
- def self.claim_latest(klass)
15
- Ductwork::JobClaim.new(klass).latest
16
- end
17
-
18
12
  def self.enqueue(step, *args)
13
+ now = Ductwork::DatabaseClock.now
19
14
  job = step.create_job!(
20
15
  klass: step.klass,
21
- started_at: Time.current,
16
+ started_at: now,
22
17
  input_args: JSON.dump({ args: })
23
18
  )
24
19
  execution = job.executions.create!(
25
- started_at: Time.current,
26
- retry_count: 0
20
+ started_at: now,
21
+ retry_count: 0,
22
+ crash_count: 0
27
23
  )
24
+
28
25
  execution.create_availability!(
29
- started_at: Time.current,
30
- pipeline_klass: step.pipeline.klass
26
+ started_at: now,
27
+ pipeline_klass: step.run.pipeline_klass
31
28
  )
32
29
 
33
30
  Ductwork.logger.info(
@@ -39,120 +36,10 @@ module Ductwork
39
36
  job
40
37
  end
41
38
 
42
- def execute(pipeline)
43
- # i don't _really_ like this, but it should be fine for now...
44
- execution = executions.order(:created_at).last
45
- Ductwork.logger.debug(
46
- msg: "Executing job",
47
- role: :job_worker,
48
- pipeline: pipeline,
49
- job_klass: klass
50
- )
51
- args = JSON.parse(input_args)["args"]
52
- instance = Object.const_get(klass).build_for_execution(step.pipeline_id, *args)
53
- run = execution.create_run!(
54
- started_at: Time.current
55
- )
56
- result = nil
57
-
58
- begin
59
- output_payload = instance.execute
60
- execution_succeeded!(execution, run, output_payload)
61
- result = "success"
62
- rescue StandardError => e
63
- execution_failed!(execution, run, e)
64
- result = "failure"
65
- ensure
66
- Ductwork.logger.info(
67
- msg: "Job executed",
68
- pipeline: pipeline,
69
- job_id: id,
70
- job_klass: klass,
71
- result: result || "killed",
72
- role: :job_worker
73
- )
74
- end
75
- end
76
-
77
39
  def return_value
78
40
  if output_payload.present?
79
41
  JSON.parse(output_payload).fetch("payload", nil)
80
42
  end
81
43
  end
82
-
83
- private
84
-
85
- def execution_succeeded!(execution, run, output_payload)
86
- payload = JSON.dump({ payload: output_payload })
87
-
88
- Ductwork::Record.transaction do
89
- update!(output_payload: payload, completed_at: Time.current)
90
- execution.update!(completed_at: Time.current)
91
- run.update!(completed_at: Time.current)
92
- execution.create_result!(result_type: "success")
93
- step.update!(status: :advancing)
94
- end
95
- end
96
-
97
- def execution_failed!(execution, run, error) # rubocop:todo Metrics
98
- halted = false
99
- pipeline = step.pipeline
100
- max_retry = Ductwork
101
- .configuration
102
- .job_worker_max_retry(pipeline: pipeline.klass, step: klass)
103
-
104
- Ductwork::Record.transaction do
105
- execution.update!(completed_at: Time.current)
106
- run.update!(completed_at: Time.current)
107
- execution.create_result!(
108
- result_type: "failure",
109
- error_klass: error.class.to_s,
110
- error_message: error.message,
111
- error_backtrace: error.backtrace.join("\n")
112
- )
113
-
114
- if execution.retry_count < max_retry
115
- new_execution = executions.create!(
116
- retry_count: execution.retry_count + 1,
117
- started_at: FAILED_EXECUTION_TIMEOUT.from_now
118
- )
119
- new_execution.create_availability!(
120
- started_at: FAILED_EXECUTION_TIMEOUT.from_now,
121
- pipeline_klass: pipeline.klass
122
- )
123
- elsif execution.retry_count >= max_retry
124
- halted = true
125
-
126
- step.update!(status: :failed)
127
- pipeline.halt!
128
- end
129
- end
130
-
131
- Ductwork.logger.warn(
132
- msg: "Job errored",
133
- error_klass: error.class.name,
134
- error_message: error.message,
135
- job_id: id,
136
- job_klass: klass,
137
- pipeline_id: pipeline.id,
138
- role: :job_worker
139
- )
140
-
141
- # NOTE: perform lifecycle hook execution outside of the transaction as
142
- # to not unnecessarily hold it open
143
- if halted
144
- execute_on_halt(pipeline, error)
145
- end
146
- end
147
-
148
- def execute_on_halt(pipeline, error)
149
- klass = JSON
150
- .parse(pipeline.definition)
151
- .dig("metadata", "on_halt", "klass")
152
-
153
- if klass.present?
154
- Object.const_get(klass).new(error).execute
155
- end
156
- end
157
44
  end
158
45
  end