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,7 +2,12 @@
2
2
 
3
3
  module Ductwork
4
4
  module Processes
5
- class ProcessSupervisor
5
+ class ProcessSupervisor # rubocop:todo Metrics/ClassLength
6
+ # Upper bound on how long sig_kill_process will block the supervisor
7
+ # loop waiting for a KILL'd child to be reaped (see sig_kill_process).
8
+ KILL_REAP_TIMEOUT = 5 # seconds
9
+ KILL_REAP_INTERVAL = 0.1
10
+
6
11
  attr_reader :workers
7
12
 
8
13
  def initialize
@@ -29,6 +34,8 @@ module Ductwork
29
34
  end
30
35
 
31
36
  def run
37
+ adopt_or_create_process!
38
+
32
39
  Ductwork.logger.debug(
33
40
  msg: "Entering main work loop",
34
41
  role: :process_supervisor,
@@ -38,6 +45,7 @@ module Ductwork
38
45
  while running_context.running?
39
46
  sleep(Ductwork.configuration.supervisor_polling_timeout)
40
47
  check_workers
48
+ reap_process_records
41
49
  end
42
50
 
43
51
  shutdown
@@ -60,6 +68,12 @@ module Ductwork
60
68
 
61
69
  attr_reader :running_context
62
70
 
71
+ def adopt_or_create_process!
72
+ Ductwork.wrap_with_app_executor do
73
+ Ductwork::Process.adopt_or_create_current!(:supervisor)
74
+ end
75
+ end
76
+
63
77
  def check_workers
64
78
  Ductwork.logger.debug(
65
79
  msg: "Checking workers are alive",
@@ -67,8 +81,10 @@ module Ductwork
67
81
  )
68
82
 
69
83
  workers.each do |worker|
70
- if process_dead?(worker[:pid])
84
+ if heartbeat_stale?(worker[:pid])
71
85
  old_pid = worker[:pid]
86
+ sig_kill_process(old_pid)
87
+ reap_process_record!(old_pid)
72
88
  new_pid = fork do
73
89
  worker[:block].call(worker[:metadata])
74
90
  end
@@ -86,6 +102,27 @@ module Ductwork
86
102
  msg: "All workers are alive or revived",
87
103
  role: :process_supervisor
88
104
  )
105
+ rescue StandardError => e
106
+ Ductwork.logger.warn(
107
+ msg: "Checking workers failed",
108
+ error_klass: e.class.to_s,
109
+ error_message: e.message,
110
+ role: :process_supervisor
111
+ )
112
+ end
113
+
114
+ def reap_process_records
115
+ Ductwork.wrap_with_app_executor do
116
+ Ductwork::Process.reap_all!(:process_supervisor)
117
+ Ductwork::Process.sweep_orphaned_claims!(:process_supervisor)
118
+ end
119
+ rescue StandardError => e
120
+ Ductwork.logger.warn(
121
+ msg: "Reaping process records failed",
122
+ error_klass: e.class.to_s,
123
+ error_message: e.message,
124
+ role: :process_supervisor
125
+ )
89
126
  end
90
127
 
91
128
  def terminate_gracefully
@@ -97,6 +134,8 @@ module Ductwork
97
134
  signal: :TERM
98
135
  )
99
136
  ::Process.kill(:TERM, worker[:pid])
137
+ rescue Errno::ESRCH
138
+ # Child already exited; nothing to terminate
100
139
  end
101
140
  end
102
141
 
@@ -121,38 +160,76 @@ module Ductwork
121
160
 
122
161
  def terminate_immediately
123
162
  workers.each_with_index do |worker, index|
124
- Ductwork.logger.debug(
163
+ Ductwork.logger.warn(
125
164
  msg: "Sending KILL signal to process (#{worker[:pid]})",
126
165
  role: :process_supervisor,
127
166
  pid: worker[:pid],
128
167
  signal: :KILL
129
168
  )
130
- ::Process.kill(:KILL, worker[:pid])
131
- ::Process.wait(worker[:pid])
169
+ sig_kill_process(worker[:pid])
170
+ reap_process_record!(worker[:pid])
132
171
  workers[index] = nil
133
- Ductwork.logger.debug(
172
+ Ductwork.logger.warn(
134
173
  msg: "Child process (#{worker[:pid]}) killed after timeout",
135
174
  role: :process_supervisor,
136
175
  pid: worker[:pid]
137
176
  )
138
- rescue Errno::ESRCH, Errno::ECHILD
139
- # no-op because process is already dead
140
177
  end
141
178
 
142
179
  @workers = workers.compact
143
180
  end
144
181
 
145
- def process_dead?(pid)
182
+ def heartbeat_stale?(pid)
146
183
  machine_identifier = Ductwork::MachineIdentifier.fetch
184
+ threshold = Ductwork.configuration.supervisor_reaper_timeout - 10.seconds
185
+ sql = Ductwork::DatabaseClock.ago_sql("last_heartbeat_at", threshold)
147
186
 
148
187
  Ductwork.wrap_with_app_executor do
149
188
  Ductwork::Process
150
189
  .where(pid:, machine_identifier:)
151
- .where("last_heartbeat_at < ?", 5.minutes.ago)
190
+ .where(sql)
152
191
  .exists?
153
192
  end
154
193
  end
155
194
 
195
+ def sig_kill_process(pid)
196
+ ::Process.kill(:KILL, pid)
197
+
198
+ deadline = now + KILL_REAP_TIMEOUT
199
+ loop do
200
+ return if ::Process.wait(pid, ::Process::WNOHANG)
201
+
202
+ if now >= deadline
203
+ # Likely wedged in uninterruptible sleep (D state): the SIGKILL is
204
+ # pending but undeliverable until its syscall returns. Don't block
205
+ # the supervisor loop on it - the kill still applies, but the OS
206
+ # process is left unreaped (a zombie) until the supervisor itself
207
+ # exits and init reaps it. The DB process record is reaped
208
+ # separately by the caller.
209
+ Ductwork.logger.warn(
210
+ msg: "Process (#{pid}) did not exit after KILL; deferring reap",
211
+ role: :process_supervisor,
212
+ pid: pid
213
+ )
214
+ return
215
+ end
216
+
217
+ sleep(KILL_REAP_INTERVAL)
218
+ end
219
+ rescue Errno::ECHILD, Errno::ESRCH
220
+ # Process is already dead or reaped
221
+ end
222
+
223
+ def reap_process_record!(pid)
224
+ machine_identifier = Ductwork::MachineIdentifier.fetch
225
+
226
+ Ductwork.wrap_with_app_executor do
227
+ Ductwork::Process
228
+ .find_by(pid:, machine_identifier:)
229
+ &.reap!(:process_supervisor, force: true)
230
+ end
231
+ end
232
+
156
233
  def run_hooks_for(event)
157
234
  Ductwork.hooks[:supervisor].fetch(event, []).each do |block|
158
235
  Ductwork.wrap_with_app_executor do
@@ -9,6 +9,7 @@ module Ductwork
9
9
  @running_context = Ductwork::RunningContext.new
10
10
  @workers = []
11
11
 
12
+ adopt_or_create_process!
12
13
  run_hooks_for(:start)
13
14
 
14
15
  Signal.trap(:INT) { @running_context.shutdown! }
@@ -48,7 +49,9 @@ module Ductwork
48
49
 
49
50
  while running_context.running?
50
51
  sleep(Ductwork.configuration.supervisor_polling_timeout)
51
- check_worker_health
52
+ check_workers_health
53
+ report_heartbeat!
54
+ reap_process_records
52
55
  end
53
56
 
54
57
  shutdown
@@ -58,26 +61,15 @@ module Ductwork
58
61
 
59
62
  attr_reader :running_context
60
63
 
61
- def check_worker_health
62
- Ductwork.logger.debug(
63
- msg: "Checking workers are alive",
64
- role: :thread_supervisor
65
- )
66
-
67
- workers.each do |worker|
68
- if !worker.alive?
69
- worker.restart
70
-
71
- Ductwork.logger.warn(
72
- msg: "Restarted supervised thread",
73
- role: :thread_supervisor,
74
- thread: worker.name
75
- )
76
- end
77
- end
78
-
79
- Ductwork.logger.debug(
80
- msg: "Checked workers are alive",
64
+ def check_workers_health
65
+ Ductwork::Processes::WorkerHealthCheck
66
+ .new(workers, :thread_supervisor)
67
+ .check
68
+ rescue StandardError => e
69
+ Ductwork.logger.warn(
70
+ msg: "Checking worker health failed",
71
+ error_klass: e.class.to_s,
72
+ error_message: e.message,
81
73
  role: :thread_supervisor
82
74
  )
83
75
  end
@@ -88,6 +80,7 @@ module Ductwork
88
80
  workers.each(&:stop)
89
81
  await_threads_graceful_shutdown
90
82
  kill_remaining_threads
83
+ reap_process_record!
91
84
  run_hooks_for(:stop)
92
85
  end
93
86
 
@@ -130,6 +123,47 @@ module Ductwork
130
123
  end
131
124
  end
132
125
 
126
+ def adopt_or_create_process!
127
+ Ductwork.wrap_with_app_executor do
128
+ Ductwork::Process.adopt_or_create_current!(:supervisor)
129
+ end
130
+ end
131
+
132
+ def report_heartbeat!
133
+ Ductwork.logger.debug(msg: "Reporting heartbeat", role: :thread_supervisor)
134
+ Ductwork.wrap_with_app_executor do
135
+ Ductwork::Process.report_heartbeat!(:supervisor)
136
+ end
137
+ Ductwork.logger.debug(msg: "Reported heartbeat", role: :thread_supervisor)
138
+ rescue StandardError => e
139
+ Ductwork.logger.warn(
140
+ msg: "Reporting heartbeat failed",
141
+ error_klass: e.class.to_s,
142
+ error_message: e.message,
143
+ role: :thread_supervisor
144
+ )
145
+ end
146
+
147
+ def reap_process_records
148
+ Ductwork.wrap_with_app_executor do
149
+ Ductwork::Process.reap_all!(:thread_supervisor)
150
+ Ductwork::Process.sweep_orphaned_claims!(:thread_supervisor)
151
+ end
152
+ rescue StandardError => e
153
+ Ductwork.logger.warn(
154
+ msg: "Reaping process records failed",
155
+ error_klass: e.class.to_s,
156
+ error_message: e.message,
157
+ role: :thread_supervisor
158
+ )
159
+ end
160
+
161
+ def reap_process_record!
162
+ Ductwork.wrap_with_app_executor do
163
+ Ductwork::Process.current&.reap!(:thread_supervisor, force: true)
164
+ end
165
+ end
166
+
133
167
  def run_hooks_for(event)
134
168
  Ductwork.hooks[:supervisor].fetch(event, []).each do |block|
135
169
  Ductwork.wrap_with_app_executor do
@@ -11,9 +11,11 @@ module Ductwork
11
11
  def run
12
12
  if Ductwork.configuration.role.in?(%w[all advancer])
13
13
  pipelines.each do |pipeline|
14
- log_created_pipeline_advancer(pipeline)
15
- supervisor.add_worker(metadata: { pipeline: }) do
16
- pipeline_advancer.new(pipeline)
14
+ Ductwork.configuration.pipeline_advancer_count(pipeline).times do |index|
15
+ log_created_pipeline_advancer(pipeline, index)
16
+ supervisor.add_worker(metadata: { pipeline: }) do
17
+ pipeline_advancer.new(pipeline, index)
18
+ end
17
19
  end
18
20
  end
19
21
  end
@@ -36,11 +38,12 @@ module Ductwork
36
38
 
37
39
  attr_reader :pipelines, :supervisor
38
40
 
39
- def log_created_pipeline_advancer(pipeline)
41
+ def log_created_pipeline_advancer(pipeline, index)
40
42
  Ductwork.logger.debug(
41
43
  msg: "Created new pipeline advancer",
42
44
  role: :thread_supervisor_runner,
43
- pipeline: pipeline
45
+ pipeline: pipeline,
46
+ index: index
44
47
  )
45
48
  end
46
49
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ module Processes
5
+ class WorkerHealthCheck
6
+ def initialize(workers, role)
7
+ @workers = workers
8
+ @role = role
9
+ end
10
+
11
+ def check
12
+ workers.each do |worker|
13
+ if !worker.alive?
14
+ restart_dead_worker(worker)
15
+ elsif worker.stuck?
16
+ restart_stuck_worker(worker)
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :workers, :role
24
+
25
+ def restart_dead_worker(worker)
26
+ worker.restart
27
+
28
+ claimed_args = if worker.is_a?(Ductwork::Processes::PipelineAdvancer)
29
+ { branch_id: worker.branch&.id }
30
+ else
31
+ { job_id: worker.execution&.job_id }
32
+ end
33
+
34
+ Ductwork.logger.warn(
35
+ msg: "Restarted dead thread",
36
+ role: role,
37
+ thread: worker.name,
38
+ **claimed_args
39
+ )
40
+ end
41
+
42
+ def restart_stuck_worker(worker)
43
+ worker.kill if worker.alive?
44
+ worker.join(1)
45
+ worker.restart
46
+
47
+ Ductwork.logger.warn(
48
+ msg: "Killed and restarted stuck thread",
49
+ role: role,
50
+ thread: worker.name
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class RowLockingExecutionClaim
5
+ def initialize(klass, owner_process_id)
6
+ @availability = nil
7
+ @execution = nil
8
+ @klass = klass
9
+ @process_id = owner_process_id
10
+ end
11
+
12
+ def latest
13
+ Ductwork::Record.transaction do
14
+ claim_availability
15
+
16
+ if availability.present?
17
+ Ductwork.logger.debug(
18
+ msg: "Job claimed",
19
+ role: :job_worker,
20
+ process_id: process_id,
21
+ availability_id: availability.id
22
+ )
23
+ update_state
24
+ else
25
+ Ductwork.logger.debug(
26
+ msg: "No available job to claim",
27
+ role: :job_worker,
28
+ process_id: process_id,
29
+ pipeline: klass
30
+ )
31
+ end
32
+ end
33
+
34
+ execution
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :availability, :execution, :klass, :process_id
40
+
41
+ def claim_availability
42
+ sql = Ductwork::DatabaseClock.now_sql("ductwork_availabilities.started_at")
43
+ @availability = Ductwork::Availability
44
+ .where(sql)
45
+ .where(completed_at: nil, pipeline_klass: klass)
46
+ .order(:started_at)
47
+ .lock("FOR UPDATE SKIP LOCKED")
48
+ .limit(1)
49
+ .first
50
+
51
+ return unless availability
52
+
53
+ completed_at = Time.current
54
+ @execution = availability.execution
55
+
56
+ availability.update_columns(completed_at:, process_id:)
57
+ execution.update_columns(process_id:)
58
+ end
59
+
60
+ def update_state
61
+ step = execution.job.step
62
+
63
+ Ductwork::Step
64
+ .where(id: step.id)
65
+ .where.not(status: "in_progress")
66
+ .update_all(status: "in_progress", updated_at: Time.current)
67
+ Ductwork::Run
68
+ .where(id: step.run_id)
69
+ .where.not(status: "in_progress")
70
+ .update_all(status: "in_progress", updated_at: Time.current)
71
+ Ductwork::Pipeline
72
+ .where(id: Ductwork::Run.where(id: step.run_id).select(:pipeline_id))
73
+ .where.not(status: "in_progress")
74
+ .update_all(status: "in_progress", updated_at: Time.current)
75
+ end
76
+ end
77
+ end
@@ -66,7 +66,7 @@ RSpec::Matchers.define(:have_set_context) do |expected|
66
66
  supports_block_expectations
67
67
 
68
68
  match do |block|
69
- ctx = Ductwork::Context.new(pipeline_id)
69
+ ctx = Ductwork::Context.new(run_id)
70
70
  before_values = expected.map { |k, _| ctx.get(k.to_s) }
71
71
 
72
72
  block.call
@@ -76,7 +76,21 @@ RSpec::Matchers.define(:have_set_context) do |expected|
76
76
  before_values.all?(&:nil?) && after_context == expected
77
77
  end
78
78
 
79
- chain :for_pipeline, :pipeline_id
79
+ chain :for_pipeline do |pipeline|
80
+ @run_id = pipeline.current_run.id
81
+ end
82
+
83
+ chain :for_run do |given_run_id|
84
+ @run_id = given_run_id
85
+ end
86
+
87
+ def run_id
88
+ if @run_id.blank?
89
+ raise ArgumentError, "Must chain with .for_pipeline or .for_run"
90
+ end
91
+
92
+ @run_id
93
+ end
80
94
 
81
95
  failure_message do
82
96
  "Context does not match expected result"
@@ -88,25 +102,28 @@ module Ductwork
88
102
  module RSpec
89
103
  def pipeline_for(klass, **attrs)
90
104
  definition = klass.pipeline_definition.to_json
91
- definition_sha1 = OpenSSL::Digest::SHA256.hexdigest(definition)
105
+ definition_sha1 = Digest::SHA1.hexdigest(definition)
106
+ pipeline_klass = klass.name.to_s
107
+ now = Time.current
92
108
  status = attrs[:status] || "in_progress"
93
- triggered_at = attrs[:triggered_at] || Time.current
94
- started_at = attrs[:started_at] || Time.current
95
- last_advanced_at = attrs[:last_advanced_at] || Time.current
109
+ triggered_at = attrs[:triggered_at] || now
110
+ started_at = attrs[:started_at] || now
96
111
 
97
- Ductwork::Pipeline.create!(
98
- klass:,
112
+ pipeline = Ductwork::Pipeline.create!(klass:, status:)
113
+ pipeline.runs.create!(
114
+ pipeline_klass:,
99
115
  definition:,
100
116
  definition_sha1:,
101
117
  status:,
102
118
  triggered_at:,
103
- started_at:,
104
- last_advanced_at:
119
+ started_at:
105
120
  )
121
+
122
+ pipeline
106
123
  end
107
124
 
108
125
  def set_pipeline_context(pipeline, **key_values)
109
- ctx = Ductwork::Context.new(pipeline.id)
126
+ ctx = Ductwork::Context.new(pipeline.current_run.id)
110
127
  key_values.each do |key, value|
111
128
  ctx.set(key.to_s, value)
112
129
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ductwork
4
- VERSION = "0.26.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/ductwork.rb CHANGED
@@ -65,6 +65,7 @@ module Ductwork
65
65
  def validate!
66
66
  step_directory = Rails.root.join("app/steps")
67
67
  pipeline_directory = Rails.root.join("app/pipelines")
68
+ workflow_directory = Rails.root.join("app/workflows")
68
69
  loader = if defined?(Rails.autoloaders)
69
70
  Rails.autoloaders.main
70
71
  else
@@ -79,6 +80,10 @@ module Ductwork
79
80
  loader.eager_load_dir(pipeline_directory)
80
81
  end
81
82
 
83
+ if workflow_directory.exist?
84
+ loader.eager_load_dir(workflow_directory)
85
+ end
86
+
82
87
  true
83
88
  end
84
89
 
@@ -17,20 +17,30 @@ module Ductwork
17
17
 
18
18
  migration_template "db/create_ductwork_pipelines.rb",
19
19
  "db/migrate/create_ductwork_pipelines.rb"
20
+ migration_template "db/create_ductwork_runs.rb",
21
+ "db/migrate/create_ductwork_runs.rb"
22
+ migration_template "db/create_ductwork_branches.rb",
23
+ "db/migrate/create_ductwork_branches.rb"
24
+ migration_template "db/create_ductwork_branch_links.rb",
25
+ "db/migrate/create_ductwork_branch_links.rb"
20
26
  migration_template "db/create_ductwork_steps.rb",
21
27
  "db/migrate/create_ductwork_steps.rb"
22
28
  migration_template "db/create_ductwork_jobs.rb",
23
29
  "db/migrate/create_ductwork_jobs.rb"
30
+ migration_template "db/create_ductwork_processes.rb",
31
+ "db/migrate/create_ductwork_processes.rb"
24
32
  migration_template "db/create_ductwork_executions.rb",
25
33
  "db/migrate/create_ductwork_executions.rb"
26
34
  migration_template "db/create_ductwork_availabilities.rb",
27
35
  "db/migrate/create_ductwork_availabilities.rb"
28
- migration_template "db/create_ductwork_runs.rb",
29
- "db/migrate/create_ductwork_runs.rb"
36
+ migration_template "db/create_ductwork_attempts.rb",
37
+ "db/migrate/create_ductwork_attempts.rb"
30
38
  migration_template "db/create_ductwork_results.rb",
31
39
  "db/migrate/create_ductwork_results.rb"
32
- migration_template "db/create_ductwork_processes.rb",
33
- "db/migrate/create_ductwork_processes.rb"
40
+ migration_template "db/create_ductwork_transitions.rb",
41
+ "db/migrate/create_ductwork_transitions.rb"
42
+ migration_template "db/create_ductwork_advancements.rb",
43
+ "db/migrate/create_ductwork_advancements.rb"
34
44
  migration_template "db/create_ductwork_tuples.rb",
35
45
  "db/migrate/create_ductwork_tuples.rb"
36
46
 
@@ -9,12 +9,16 @@ default: &default
9
9
  polling_timeout: 1
10
10
  shutdown_timeout: 20
11
11
  pipeline_advancer:
12
+ count: 3
13
+ max_crash: 10
14
+ max_retry: 3
12
15
  polling_timeout: 1
13
16
  shutdown_timeout: 20
14
17
  steps:
15
18
  max_depth: -1
16
19
  supervisor:
17
20
  polling_timeout: 1
21
+ reaper_timeout: 300
18
22
  shutdown_timeout: 30
19
23
 
20
24
  development:
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkAdvancements < Ductwork::Migration
4
+ def change
5
+ create_ductwork_table :ductwork_advancements do |table|
6
+ belongs_to(
7
+ table,
8
+ :process,
9
+ index: true,
10
+ null: true,
11
+ foreign_key: { to_table: :ductwork_processes }
12
+ )
13
+ belongs_to(
14
+ table,
15
+ :transition,
16
+ index: true,
17
+ null: false,
18
+ foreign_key: { to_table: :ductwork_transitions }
19
+ )
20
+ table.datetime :started_at, null: false
21
+ table.datetime :completed_at
22
+ table.integer :crash_count
23
+ table.string :error_klass
24
+ table.string :error_message
25
+ table.text :error_backtrace
26
+ table.timestamps null: false
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDuctworkAttempts < Ductwork::Migration
4
+ def change
5
+ create_ductwork_table :ductwork_attempts do |table|
6
+ belongs_to(
7
+ table,
8
+ :execution,
9
+ index: false,
10
+ null: false,
11
+ foreign_key: { to_table: :ductwork_executions }
12
+ )
13
+ table.timestamp :started_at, null: false
14
+ table.timestamp :completed_at
15
+ table.timestamps null: false
16
+ end
17
+
18
+ add_index :ductwork_attempts, :execution_id, unique: true
19
+ end
20
+ end
@@ -10,18 +10,31 @@ class CreateDuctworkAvailabilities < Ductwork::Migration
10
10
  null: false,
11
11
  foreign_key: { to_table: :ductwork_executions }
12
12
  )
13
+ belongs_to(
14
+ table,
15
+ :process,
16
+ index: true,
17
+ null: true,
18
+ foreign_key: { to_table: :ductwork_processes }
19
+ )
13
20
  table.timestamp :started_at, null: false
14
21
  table.timestamp :completed_at
15
- table.integer :process_id
16
22
  table.string :pipeline_klass, null: false
17
23
  table.timestamps null: false
18
24
  end
19
25
 
20
26
  add_index :ductwork_availabilities, :execution_id, unique: true
21
27
  add_index :ductwork_availabilities, %i[id process_id]
22
- add_index :ductwork_availabilities,
23
- %i[pipeline_klass started_at],
24
- name: "index_ductwork_availabilities_on_claim_latest",
25
- where: "completed_at IS NULL"
28
+
29
+ if mysql?
30
+ add_index :ductwork_availabilities,
31
+ %i[pipeline_klass completed_at started_at],
32
+ name: "index_ductwork_availabilities_on_claim_latest"
33
+ else
34
+ add_index :ductwork_availabilities,
35
+ %i[pipeline_klass started_at],
36
+ name: "index_ductwork_availabilities_on_claim_latest",
37
+ where: "completed_at IS NULL"
38
+ end
26
39
  end
27
40
  end