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
@@ -5,8 +5,8 @@
5
5
  </h1>
6
6
 
7
7
  <h2>
8
- <span class="status-pill big-pill <%= @pipeline.status %>">
9
- <%= @pipeline.status.gsub("_", "-") %>
8
+ <span class="status-pill big-pill <%= @last_run.status %>">
9
+ <%= @last_run.status.gsub("_", "-") %>
10
10
  </span>
11
11
  </h2>
12
12
  </div>
@@ -30,34 +30,34 @@
30
30
  <div>
31
31
  <label>Runtime</label>
32
32
  <span>
33
- <% if @pipeline.completed_at.nil? %>
34
- <div data-started-at=<%= @pipeline.started_at.iso8601 %>>
33
+ <% if @last_run.completed_at.nil? %>
34
+ <div data-started-at=<%= @last_run.started_at.iso8601 %>>
35
35
  <span id="elapsed-timer">0h 0m 0s</span>
36
36
  </div>
37
37
  <% else %>
38
- <%= formatted_time_distance(@pipeline.started_at, @pipeline.completed_at) %>
38
+ <%= formatted_time_distance(@last_run.started_at, @last_run.completed_at) %>
39
39
  <% end %>
40
40
  </span>
41
41
  </div>
42
42
  <div class="metric-heading">
43
43
  <label>Triggered At</label>
44
44
  <span>
45
- <%= @pipeline.triggered_at.iso8601(3) %>
45
+ <%= @last_run.triggered_at.iso8601(3) %>
46
46
  </span>
47
47
  </div>
48
48
  </div>
49
49
 
50
50
  <div>
51
51
  <div class="metric-heading">
52
- <label>Last Advanced At</label>
52
+ <label>Started At</label>
53
53
  <span>
54
- <%= @pipeline.last_advanced_at.iso8601(3) %>
54
+ <%= @last_run.started_at.iso8601(3) %>
55
55
  </span>
56
56
  </div>
57
57
  <div>
58
58
  <label>Completed At</label>
59
59
  <span>
60
- <%= @pipeline.completed_at&.iso8601(3) %>
60
+ <%= @last_run.completed_at&.iso8601(3) %>
61
61
  </span>
62
62
  </div>
63
63
  </div>
@@ -66,12 +66,12 @@
66
66
  <div style="margin-top: 1rem;">
67
67
  <label>Definition</label>
68
68
  <code style="width: 100%;">
69
- <%= @pipeline.parsed_definition %>
69
+ <%= @last_run.parsed_definition %>
70
70
  </code>
71
71
  </div>
72
72
  </article>
73
73
 
74
- <h3>Steps (<%= @pipeline.steps.count %>)</h3>
74
+ <h3>Steps (<%= @last_run.steps.count %>)</h3>
75
75
 
76
76
  <div class="separate-content">
77
77
  <div class="filter-group">
@@ -245,7 +245,7 @@
245
245
  <td><%= execution.retry_count + 1 %></td>
246
246
  <td><%= execution.started_at.strftime("%H:%M:%S.%3N") %></td>
247
247
  <td><%= execution.availability.completed_at&.strftime("%H:%M:%S.%3N") %></td>
248
- <td><%= execution.run&.started_at&.strftime("%H:%M:%S.%3N") %></td>
248
+ <td><%= execution.attempt&.started_at&.strftime("%H:%M:%S.%3N") %></td>
249
249
  <td><%= execution.completed_at&.strftime("%H:%M:%S.%3N") %></td>
250
250
  <td>
251
251
  <% if execution.result.present? && execution.result.failure? %>
@@ -269,7 +269,7 @@
269
269
  <%= execution.result.error_message %>
270
270
  </h5>
271
271
  <code>
272
- <%= execution.result.error_backtrace.split("\n").each do |line| %>
272
+ <%= (execution.result.error_backtrace&.split("\n") || []).each do |line| %>
273
273
  <div><%= line %></div>
274
274
  <% end %>
275
275
  </code>
@@ -56,7 +56,7 @@
56
56
  </td>
57
57
  <td>
58
58
  <code>
59
- <%= link_to(result.execution.job.step.pipeline_id, pipeline_path(result.execution.job.step.pipeline_id)) %>
59
+ <%= link_to(result.execution.job.step.run.pipeline_id, pipeline_path(result.execution.job.step.run.pipeline_id)) %>
60
60
  </code>
61
61
  </td>
62
62
  <td>
@@ -85,7 +85,7 @@
85
85
  <%= result.error_message %>
86
86
  </h5>
87
87
  <code>
88
- <%= result.error_backtrace.split("\n").each do |line| %>
88
+ <%= (result.error_backtrace&.split("\n") || []).each do |line| %>
89
89
  <div><%= line %></div>
90
90
  <% end %>
91
91
  </code>
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class BranchClaim
5
+ attr_reader :transition, :advancement, :token
6
+
7
+ def initialize(pipeline_klass)
8
+ @pipeline_klass = pipeline_klass
9
+ @claimed_for_advancing_at = nil
10
+ end
11
+
12
+ def latest
13
+ # NOTE: an advancement must attach to a live process record so the reaper
14
+ # can find and recover it if this process dies. `Process.current` is only
15
+ # nil when our record was reaped for a stale heartbeat (e.g. host suspend)
16
+ # and the runner has not re-adopted it yet — i.e. the system has already
17
+ # declared this process dead. Claiming as a presumed-dead zombie would
18
+ # create an advancement with a nil `process_id` that no reaper sweep can
19
+ # reach, orphaning the branch. Skip this cycle instead; the runner
20
+ # re-adopts within its heartbeat interval.
21
+ process = Ductwork::Process.current
22
+
23
+ return log_no_process if process.nil?
24
+
25
+ id = find_candidate_branch_id
26
+
27
+ return log_no_branches if id.blank?
28
+
29
+ rows_updated = claim_and_setup_records(id, process)
30
+
31
+ if rows_updated == 1
32
+ Ductwork::Branch.find(id)
33
+ else
34
+ log_race_condition(id)
35
+ end
36
+ rescue ActiveRecord::InvalidForeignKey => e
37
+ # NOTE: our own `process` record was reaped (heartbeat-stale, destroyed)
38
+ # between the nil-check above and `transition.advancements.create!`
39
+ # inside `claim_and_setup_records`. The insert's FK check blocks on the
40
+ # reaper's row lock and then fails once the parent is gone, rolling back
41
+ # the whole claim transaction (branch + transition + advancement) --
42
+ # nothing is left half-committed. Treat it like any other lost claim
43
+ # race instead of letting it propagate and kill the advancer thread.
44
+ log_process_reaped_mid_claim(id, e)
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :pipeline_klass, :claimed_for_advancing_at
50
+
51
+ def find_candidate_branch_id
52
+ Ductwork::Branch
53
+ .in_progress
54
+ .where(pipeline_klass:, claimed_for_advancing_at:)
55
+ .where(steps: Ductwork::Step.where(status: %w[advancing failed]))
56
+ .order(:last_advanced_at)
57
+ .limit(1)
58
+ .pluck(:id)
59
+ .first
60
+ end
61
+
62
+ def claim_and_setup_records(id, process)
63
+ now = Time.current
64
+ @token = SecureRandom.uuid
65
+
66
+ Ductwork::Record.transaction do
67
+ rows_updated = Ductwork::Branch
68
+ .where(id:, claimed_for_advancing_at:)
69
+ .update_all(
70
+ claimed_for_advancing_at: now,
71
+ claim_token: token,
72
+ status: :advancing
73
+ )
74
+
75
+ if rows_updated == 1
76
+ branch = Branch.find(id)
77
+ @transition = find_or_create_transition(branch, now)
78
+ Ductwork::FaultInjection.checkpoint(:before_advancement_create)
79
+ @advancement = transition.advancements.create!(
80
+ process: process,
81
+ started_at: now,
82
+ crash_count: next_crash_count(transition)
83
+ )
84
+ end
85
+
86
+ rows_updated
87
+ end
88
+ end
89
+
90
+ def find_or_create_transition(branch, now)
91
+ existing = branch
92
+ .transitions
93
+ .where(completed_at: nil)
94
+ .order(started_at: :desc)
95
+ .limit(1)
96
+ .first
97
+
98
+ if existing
99
+ fail_abandoned_advancement(existing, now)
100
+ existing
101
+ else
102
+ branch.transitions.create!(
103
+ in_step: branch.latest_step,
104
+ started_at: now
105
+ )
106
+ end
107
+ end
108
+
109
+ # NOTE: each crash spawns a fresh advancement at re-claim, so the running
110
+ # crash total lives on the advancement and carries forward (mirroring
111
+ # `Ductwork::Execution#crash_count`). The prior advancement is read after
112
+ # `find_or_create_transition` has already marked any abandoned in-flight
113
+ # advancement as a crash, so both the reaper/thread-cleanup path and the
114
+ # `fail_abandoned_advancement` path are reflected here. A fresh transition
115
+ # starts at 0; a non-crash errored prior carries the total unchanged.
116
+ def next_crash_count(transition)
117
+ prior = transition.advancements.order(started_at: :desc).first
118
+ base = prior&.crash_count || 0
119
+
120
+ if prior&.crash?
121
+ base + 1
122
+ else
123
+ base
124
+ end
125
+ end
126
+
127
+ def fail_abandoned_advancement(transition, now)
128
+ transition
129
+ .advancements
130
+ .where(completed_at: nil)
131
+ .order(started_at: :desc)
132
+ .limit(1)
133
+ .first
134
+ &.update!(
135
+ completed_at: now,
136
+ error_klass: "Ductwork::ProcessCrash",
137
+ error_message: "Advancement was abandoned from a process crash"
138
+ )
139
+ end
140
+
141
+ def log_no_process
142
+ Ductwork.logger.debug(
143
+ msg: "No live process record, skipping branch claim",
144
+ pipeline: pipeline_klass,
145
+ role: :pipeline_advancer
146
+ )
147
+
148
+ nil
149
+ end
150
+
151
+ def log_no_branches
152
+ Ductwork.logger.debug(
153
+ msg: "No branches needs advancing",
154
+ pipeline: pipeline_klass,
155
+ role: :pipeline_advancer
156
+ )
157
+
158
+ nil
159
+ end
160
+
161
+ def log_race_condition(id)
162
+ Ductwork.logger.debug(
163
+ msg: "Did not claim branch, avoided race condition",
164
+ branch_id: id,
165
+ pipeline_klass: pipeline_klass,
166
+ role: :pipeline_advancer
167
+ )
168
+
169
+ nil
170
+ end
171
+
172
+ def log_process_reaped_mid_claim(id, error)
173
+ Ductwork.logger.warn(
174
+ msg: "Did not claim branch, our process record was reaped mid-claim",
175
+ branch_id: id,
176
+ pipeline_klass: pipeline_klass,
177
+ error_klass: error.class.to_s,
178
+ error_message: error.message,
179
+ role: :pipeline_advancer
180
+ )
181
+
182
+ nil
183
+ end
184
+ end
185
+ end
data/lib/ductwork/cli.rb CHANGED
@@ -4,49 +4,60 @@ require "optparse"
4
4
 
5
5
  module Ductwork
6
6
  class CLI
7
+ DEFAULT_COMMAND = "start"
8
+
7
9
  def self.start!(args)
8
10
  new(args).start!
9
11
  end
10
12
 
11
13
  def initialize(args)
12
- @args = args
13
- @options = {}
14
- end
15
-
16
- def start!
17
- option_parser.parse!(args)
18
- auto_configure
19
- puts banner
20
- launch_processes
21
- end
22
-
23
- private
24
-
25
- attr_reader :args, :options
26
-
27
- def option_parser
28
- OptionParser.new do |op|
14
+ @raw_args = args.dup
15
+ @configuration_options = {}
16
+ @health_check_options = {}
17
+ @top_level_parser = OptionParser.new do |op|
29
18
  op.banner = "ductwork [options]"
30
19
 
31
20
  op.on("-c", "--config PATH", "path to YAML config file") do |arg|
32
- options[:path] = arg
21
+ configuration_options[:path] = arg
33
22
  end
34
23
 
35
24
  op.on("-h", "--help", "Prints this help") do
36
25
  puts op
26
+ puts "\nCommands:\n start\n health"
37
27
  exit
38
28
  end
29
+ end
30
+ end
39
31
 
40
- op.on("-v", "--version", "Prints the version") do
41
- puts "Ductwork #{Ductwork::VERSION}"
42
- exit
43
- end
32
+ def start!
33
+ parse!
34
+ auto_configure
35
+ execute_command
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :raw_args, :configuration_options, :health_check_options,
41
+ :top_level_parser, :command
42
+
43
+ def parse!
44
+ top_level_parser.order(raw_args)
45
+ @command = raw_args.shift || DEFAULT_COMMAND
46
+
47
+ if command == "start"
48
+ start_command_parser.parse!(raw_args)
49
+ elsif command == "health"
50
+ health_command_parser.parse!(raw_args)
51
+ else
52
+ warn "Unknown command: #{command}"
53
+ puts top_level_parser
54
+ exit 1
44
55
  end
45
56
  end
46
57
 
47
58
  def auto_configure
48
- options[:role] = ENV.fetch("DUCTWORK_ROLE", nil)
49
- Ductwork.configuration = Configuration.new(**options)
59
+ configuration_options[:role] = ENV.fetch("DUCTWORK_ROLE", nil)
60
+ Ductwork.configuration = Configuration.new(**configuration_options)
50
61
  Ductwork.logger = if Ductwork.configuration.logger_source == "rails"
51
62
  Rails.logger
52
63
  else
@@ -73,8 +84,46 @@ module Ductwork
73
84
  BANNER
74
85
  end
75
86
 
87
+ def execute_command
88
+ if command == "start"
89
+ launch_processes
90
+ elsif command == "health"
91
+ check_health
92
+ end
93
+ end
94
+
76
95
  def launch_processes
96
+ puts banner
97
+
77
98
  Ductwork::Processes::Launcher.start_processes!
78
99
  end
100
+
101
+ def check_health
102
+ Ductwork::Processes::HealthCheck.run(**health_check_options)
103
+ end
104
+
105
+ def start_command_parser
106
+ OptionParser.new do |op|
107
+ op.banner = "ductwork start [options]"
108
+
109
+ op.on("-c", "--config PATH") do |arg|
110
+ configuration_options[:path] = arg
111
+ end
112
+ end
113
+ end
114
+
115
+ def health_command_parser
116
+ OptionParser.new do |op|
117
+ op.banner = "ductwork health [options]"
118
+
119
+ op.on("-p", "--pid PID") do |arg|
120
+ health_check_options[:pid] = arg.to_i
121
+ end
122
+
123
+ op.on("-V", "--verbose", "Prints all the health check data") do
124
+ health_check_options[:verbose] = true
125
+ end
126
+ end
127
+ end
79
128
  end
80
129
  end
@@ -6,18 +6,24 @@ module Ductwork
6
6
  DEFAULT_FILE_PATH = "config/ductwork.yml"
7
7
  DEFAULT_FORKING = "default" # fork pipeline advancer and job workers
8
8
  DEFAULT_JOB_WORKER_COUNT = 5 # threads
9
+ DEFAULT_JOB_WORKER_MAX_CRASH = 10 # attempts
9
10
  DEFAULT_JOB_WORKER_MAX_RETRY = 3 # attempts
10
11
  DEFAULT_JOB_WORKER_POLLING_TIMEOUT = 1 # second
11
12
  DEFAULT_JOB_WORKER_SHUTDOWN_TIMEOUT = 20 # seconds
12
13
  DEFAULT_LOGGER_LEVEL = ::Logger::INFO
13
14
  DEFAULT_LOGGER_SOURCE = "default" # `Logger` instance writing to STDOUT
15
+ DEFAULT_PIPELINE_ADVANCER_MAX_CRASH = 10 # attempts
16
+ DEFAULT_PIPELINE_ADVANCER_MAX_RETRY = 10 # attempts
17
+ DEFAULT_PIPELINE_ADVANCER_THREAD_COUNT = 3 # threads
14
18
  DEFAULT_PIPELINE_POLLING_TIMEOUT = 1 # second
15
19
  DEFAULT_PIPELINE_SHUTDOWN_TIMEOUT = 20 # seconds
16
20
  DEFAULT_ROLE = "all" # supervisor, pipeline advancer, and job workers
17
21
  DEFAULT_STEPS_MAX_DEPTH = -1 # unlimited count
18
22
  DEFAULT_SUPERVISOR_POLLING_TIMEOUT = 1 # second
23
+ DEFAULT_SUPERVISOR_REAPER_TIMEOUT = 300 # seconds
19
24
  DEFAULT_SUPERVISOR_SHUTDOWN_TIMEOUT = 30 # seconds
20
25
  DEFAULT_LOGGER = ::Logger.new($stdout)
26
+ PIPELINES_DIRECTORIES = %w[app/pipelines app/workflows].freeze
21
27
  PIPELINES_WILDCARD = "*"
22
28
  VALID_ROLES = %w[all advancer worker].freeze
23
29
 
@@ -25,10 +31,14 @@ module Ductwork
25
31
 
26
32
  attr_writer :job_worker_count, :job_worker_polling_timeout,
27
33
  :job_worker_shutdown_timeout, :job_worker_max_retry,
34
+ :job_worker_max_crash,
28
35
  :logger_level,
36
+ :pipeline_advancer_count, :pipeline_advancer_max_crash,
37
+ :pipeline_advancer_max_retry,
29
38
  :pipeline_polling_timeout, :pipeline_shutdown_timeout,
30
39
  :steps_max_depth,
31
- :supervisor_polling_timeout, :supervisor_shutdown_timeout
40
+ :supervisor_polling_timeout, :supervisor_reaper_timeout,
41
+ :supervisor_shutdown_timeout
32
42
 
33
43
  def initialize(path: DEFAULT_FILE_PATH, role: nil)
34
44
  full_path = Pathname.new(path)
@@ -57,9 +67,10 @@ module Ductwork
57
67
  raw_pipelines = config[:pipelines] || []
58
68
 
59
69
  if raw_pipelines == PIPELINES_WILDCARD
60
- Dir
61
- .glob("**/*.rb", base: "app/pipelines")
70
+ PIPELINES_DIRECTORIES
71
+ .flat_map { |dir| Dir.glob("**/*.rb", base: dir) }
62
72
  .map { |path| path.delete_suffix(".rb").camelize }
73
+ .uniq
63
74
  else
64
75
  raw_pipelines.map(&:strip)
65
76
  end
@@ -81,6 +92,24 @@ module Ductwork
81
92
  end
82
93
  end
83
94
 
95
+ def job_worker_max_crash(pipeline: nil, step: nil) # rubocop:disable Metrics
96
+ return @job_worker_max_crash if instance_variable_defined?(:@job_worker_max_crash)
97
+
98
+ pipeline ||= :default
99
+ step ||= :default
100
+ base_config = config.dig(:job_worker, :max_crash)
101
+
102
+ if base_config.is_a?(Hash) && base_config[pipeline.to_sym].is_a?(Hash)
103
+ pipeline_config = config.dig(:job_worker, :max_crash, pipeline.to_sym)
104
+
105
+ pipeline_config[step.to_sym] || pipeline_config[:default] || DEFAULT_JOB_WORKER_MAX_CRASH
106
+ elsif base_config.is_a?(Hash)
107
+ base_config[pipeline.to_sym] || base_config[:default] || DEFAULT_JOB_WORKER_MAX_CRASH
108
+ else
109
+ base_config || DEFAULT_JOB_WORKER_MAX_CRASH
110
+ end
111
+ end
112
+
84
113
  def job_worker_max_retry(pipeline: nil, step: nil) # rubocop:disable Metrics
85
114
  return @job_worker_max_retry if instance_variable_defined?(:@job_worker_max_retry)
86
115
 
@@ -125,6 +154,28 @@ module Ductwork
125
154
  @logger_source ||= fetch_logger_source
126
155
  end
127
156
 
157
+ def pipeline_advancer_count(pipeline = nil)
158
+ pipeline ||= :default
159
+ default = DEFAULT_PIPELINE_ADVANCER_THREAD_COUNT
160
+ base_config = config.dig(:pipeline_advancer, :count)
161
+
162
+ if instance_variable_defined?(:@pipeline_advancer_count)
163
+ @pipeline_advancer_count
164
+ elsif base_config.is_a?(Hash)
165
+ base_config[pipeline.to_sym] || base_config[:default] || default
166
+ else
167
+ base_config || default
168
+ end
169
+ end
170
+
171
+ def pipeline_advancer_max_crash
172
+ @pipeline_advancer_max_crash ||= fetch_pipeline_advancer_max_crash
173
+ end
174
+
175
+ def pipeline_advancer_max_retry
176
+ @pipeline_advancer_max_retry ||= fetch_pipeline_advancer_max_retry
177
+ end
178
+
128
179
  def pipeline_polling_timeout(pipeline = nil)
129
180
  pipeline ||= :default
130
181
  default = DEFAULT_PIPELINE_POLLING_TIMEOUT
@@ -169,6 +220,10 @@ module Ductwork
169
220
  @supervisor_polling_timeout ||= fetch_supervisor_polling_timeout
170
221
  end
171
222
 
223
+ def supervisor_reaper_timeout
224
+ @supervisor_reaper_timeout ||= fetch_supervisor_reaper_timeout
225
+ end
226
+
172
227
  def supervisor_shutdown_timeout
173
228
  @supervisor_shutdown_timeout ||= fetch_supervisor_shutdown_timeout
174
229
  end
@@ -190,6 +245,16 @@ module Ductwork
190
245
  config.dig(:logger, :source) || DEFAULT_LOGGER_SOURCE
191
246
  end
192
247
 
248
+ def fetch_pipeline_advancer_max_crash
249
+ config.dig(:pipeline_advancer, :max_crash) ||
250
+ DEFAULT_PIPELINE_ADVANCER_MAX_CRASH
251
+ end
252
+
253
+ def fetch_pipeline_advancer_max_retry
254
+ config.dig(:pipeline_advancer, :max_retry) ||
255
+ DEFAULT_PIPELINE_ADVANCER_MAX_RETRY
256
+ end
257
+
193
258
  def fetch_pipeline_shutdown_timeout
194
259
  config.dig(:pipeline_advancer, :shutdown_timeout) ||
195
260
  DEFAULT_PIPELINE_SHUTDOWN_TIMEOUT
@@ -200,6 +265,11 @@ module Ductwork
200
265
  DEFAULT_SUPERVISOR_POLLING_TIMEOUT
201
266
  end
202
267
 
268
+ def fetch_supervisor_reaper_timeout
269
+ config.dig(:supervisor, :reaper_timeout) ||
270
+ DEFAULT_SUPERVISOR_REAPER_TIMEOUT
271
+ end
272
+
203
273
  def fetch_supervisor_shutdown_timeout
204
274
  config.dig(:supervisor, :shutdown_timeout) ||
205
275
  DEFAULT_SUPERVISOR_SHUTDOWN_TIMEOUT
@@ -4,42 +4,40 @@ module Ductwork
4
4
  class Context
5
5
  class OverwriteError < StandardError; end
6
6
 
7
- def initialize(pipeline_id)
8
- @pipeline_id = pipeline_id
7
+ def initialize(run_id)
8
+ @run_id = run_id
9
9
  end
10
10
 
11
11
  def get(key)
12
12
  raise ArgumentError, "Key must be a string" if !key.is_a?(String)
13
13
 
14
- Ductwork.wrap_with_app_executor do
15
- Ductwork::Tuple
16
- .select(:serialized_value)
17
- .find_by(pipeline_id:, key:)
18
- &.value
19
- end
14
+ Ductwork::Tuple
15
+ .select(:serialized_value)
16
+ .find_by(run_id:, key:)
17
+ &.value
20
18
  end
21
19
 
22
20
  def set(key, value, overwrite: false)
23
21
  attributes = {
24
22
  id: SecureRandom.uuid_v7,
25
- pipeline_id: pipeline_id,
23
+ run_id: run_id,
26
24
  key: key,
27
25
  serialized_value: Ductwork::Tuple.serialize(value),
28
26
  first_set_at: Time.current,
29
27
  last_set_at: Time.current,
30
28
  }
31
- unique_by = %i[pipeline_id key]
29
+ opts = if Ductwork::Tuple.connection.adapter_name.match?(/mysql|trilogy/i)
30
+ {}
31
+ else
32
+ { unique_by: %i[run_id key] }
33
+ end
32
34
 
33
35
  if overwrite
34
- Ductwork.wrap_with_app_executor do
35
- Ductwork::Tuple.upsert(attributes, unique_by:)
36
- end
36
+ Ductwork::Tuple.upsert(attributes, **opts)
37
37
  else
38
- result = Ductwork.wrap_with_app_executor do
39
- Ductwork::Tuple.insert(attributes, unique_by:)
40
- end
41
-
42
- if result.rows.none?
38
+ begin
39
+ Ductwork::Tuple.create!(attributes)
40
+ rescue ActiveRecord::RecordNotUnique
43
41
  raise Ductwork::Context::OverwriteError, "Can only set value once"
44
42
  end
45
43
  end
@@ -49,6 +47,6 @@ module Ductwork
49
47
 
50
48
  private
51
49
 
52
- attr_reader :pipeline_id
50
+ attr_reader :run_id
53
51
  end
54
52
  end