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
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ # NOTE: these are SQL fragments that resolve against the database server's
5
+ # clock instead of the calling Ruby process's clock. use them in `WHERE`
6
+ # clauses that compare between a stored timestamp and "now" so that NTP
7
+ # drift between hosts cannot make healthy work look stale (or vice versa)
8
+ class DatabaseClock
9
+ def self.ago_sql(column, interval)
10
+ new(column).ago_sql(interval)
11
+ end
12
+
13
+ def self.now_sql(column)
14
+ new(column).now_sql
15
+ end
16
+
17
+ def self.now
18
+ adapter = Ductwork::Record.connection.adapter_name.downcase
19
+ sql =
20
+ case adapter
21
+ when /postgresql|cockroach/i
22
+ "SELECT clock_timestamp()"
23
+ when /mysql|trilogy/i
24
+ "SELECT CURRENT_TIMESTAMP(6)"
25
+ when /sqlite/i
26
+ "SELECT strftime('%Y-%m-%d %H:%M:%f', 'now')"
27
+ when /oracle/i
28
+ "SELECT CURRENT_TIMESTAMP FROM dual"
29
+ else
30
+ raise NotImplementedError, "Database clock does not support adapter #{adapter}"
31
+ end
32
+ raw = Ductwork::Record.connection.select_value(sql)
33
+
34
+ case raw
35
+ when ::Time, ::DateTime
36
+ raw.in_time_zone
37
+ else
38
+ # NOTE: SQLite returns an ISO-8601 string already expressed in UTC
39
+ ::Time.find_zone!("UTC").parse(raw.to_s)
40
+ end
41
+ end
42
+
43
+ def initialize(column)
44
+ @adapter = Ductwork::Record.connection.adapter_name.downcase
45
+ @column = column
46
+ end
47
+
48
+ def ago_sql(interval)
49
+ seconds = interval.to_i
50
+
51
+ case adapter
52
+ when /postgresql|cockroach/i
53
+ "#{column} <= clock_timestamp() - INTERVAL '#{seconds} seconds'"
54
+ when /mysql|trilogy/i
55
+ "#{column} <= CURRENT_TIMESTAMP(6) - INTERVAL #{seconds} SECOND"
56
+ when /sqlite/i
57
+ "julianday(#{column}) <= julianday('now', '-#{seconds} seconds')"
58
+ when /oracle/i
59
+ "#{column} <= CURRENT_TIMESTAMP - NUMTODSINTERVAL(#{seconds}, 'SECOND')"
60
+ else
61
+ raise NotImplementedError, "Database clock does not support adapter #{adapter}"
62
+ end
63
+ end
64
+
65
+ def now_sql
66
+ case adapter
67
+ when /sqlite/i
68
+ "julianday(#{column}) <= julianday('now')"
69
+ when /mysql|trilogy/i
70
+ "#{column} <= CURRENT_TIMESTAMP(6)"
71
+ when /postgresql|cockroach/i
72
+ "#{column} <= clock_timestamp()"
73
+ when /oracle/i
74
+ "#{column} <= CURRENT_TIMESTAMP"
75
+ else
76
+ raise NotImplementedError, "Database clock does not support adapter #{adapter}"
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ attr_reader :adapter, :column
83
+ end
84
+ end
@@ -11,6 +11,7 @@ module Ductwork
11
11
  @last_node = last_node
12
12
  @definition = definition
13
13
  @expansions = 0
14
+ @expand_sources = []
14
15
  end
15
16
 
16
17
  def chain(next_klass)
@@ -43,6 +44,31 @@ module Ductwork
43
44
  self
44
45
  end
45
46
 
47
+ def divert(to:)
48
+ to_map = {}
49
+ next_nodes = []
50
+ to.each do |key, klass|
51
+ node = node_name(klass)
52
+ definition[:edges][node] ||= { klass: klass.name }
53
+ to_map[key.to_s] = node
54
+ next_nodes << node
55
+ end
56
+
57
+ definition[:edges][last_node][:to] = to_map
58
+ definition[:edges][last_node][:type] = :divert
59
+ definition[:nodes].push(*next_nodes)
60
+
61
+ if block_given?
62
+ sub_branches = next_nodes.map do |last_node|
63
+ Ductwork::DSL::BranchBuilder.new(last_node:, definition:)
64
+ end
65
+
66
+ yield sub_branches
67
+ end
68
+
69
+ self
70
+ end
71
+
46
72
  def combine(*branch_builders, into:)
47
73
  next_klass_name = node_name(into)
48
74
  definition[:edges][last_node][:to] = [next_klass_name]
@@ -58,8 +84,26 @@ module Ductwork
58
84
  self
59
85
  end
60
86
 
87
+ def converge(*branch_builders, into:)
88
+ next_klass_name = node_name(into)
89
+ definition[:edges][last_node][:to] = [next_klass_name]
90
+ definition[:edges][last_node][:type] = :converge
91
+
92
+ branch_builders.each do |branch|
93
+ definition[:edges][branch.last_node][:to] = [next_klass_name]
94
+ definition[:edges][branch.last_node][:type] = :converge
95
+ end
96
+ definition[:nodes].push(next_klass_name)
97
+ definition[:edges][next_klass_name] ||= { klass: into.name }
98
+
99
+ self
100
+ end
101
+
61
102
  def expand(to:)
62
103
  next_klass_name = node_name(to)
104
+ # NOTE: record the expanding node so the matching `collapse` can stamp it
105
+ # as `barrier_node` (see `Ductwork::DSL::DefinitionBuilder#expand`).
106
+ expand_sources.push(last_node)
63
107
  definition[:edges][last_node][:to] = [next_klass_name]
64
108
  definition[:edges][last_node][:type] = :expand
65
109
  definition[:nodes].push(next_klass_name)
@@ -79,6 +123,7 @@ module Ductwork
79
123
  next_klass_name = node_name(into)
80
124
  definition[:edges][last_node][:to] = [next_klass_name]
81
125
  definition[:edges][last_node][:type] = :collapse
126
+ definition[:edges][last_node][:barrier_node] = expand_sources.pop
82
127
 
83
128
  definition[:nodes].push(next_klass_name)
84
129
  definition[:edges][next_klass_name] ||= { klass: into.name }
@@ -90,7 +135,7 @@ module Ductwork
90
135
 
91
136
  private
92
137
 
93
- attr_reader :definition, :expansions
138
+ attr_reader :definition, :expansions, :expand_sources
94
139
 
95
140
  def node_name(klass)
96
141
  "#{klass.name}.#{SecureRandom.hex(4)}"
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Ductwork
4
4
  module DSL
5
- class DefinitionBuilder
5
+ class DefinitionBuilder # rubocop:todo Metrics/ClassLength
6
6
  class StartError < StandardError; end
7
7
  class CollapseError < StandardError; end
8
8
  class CombineError < StandardError; end
9
+ class DivertError < StandardError; end
10
+ class ConvergeError < StandardError; end
9
11
 
10
12
  def initialize
11
13
  @definition = {
@@ -15,6 +17,7 @@ module Ductwork
15
17
  }
16
18
  @divergences = []
17
19
  @last_nodes = []
20
+ @expand_sources = []
18
21
  end
19
22
 
20
23
  def start(klass)
@@ -96,6 +99,13 @@ module Ductwork
96
99
  def expand(to:)
97
100
  validate_classes!(to)
98
101
  validate_definition_started!(action: "expanding chain")
102
+
103
+ # NOTE: record the node(s) carrying this `expand` edge before
104
+ # `add_edge_to_last_nodes` reassigns `@last_nodes`. The matching
105
+ # `collapse` stamps this on its edge as `barrier_node` so the runtime
106
+ # fan-in can find the expanding branch (which carries the barrier) no
107
+ # matter how many transitions sit between the `expand` and `collapse`.
108
+ expand_sources.push(last_nodes.dup)
99
109
  add_edge_to_last_nodes(to, type: :expand)
100
110
  divergences.push(:expand)
101
111
 
@@ -106,8 +116,73 @@ module Ductwork
106
116
  validate_classes!(into)
107
117
  validate_definition_started!(action: "collapsing steps")
108
118
  validate_can_collapse!
119
+ collapse_sources = last_nodes.dup
109
120
  add_edge_to_last_nodes(into, type: :collapse)
110
121
  divergences.pop
122
+ barrier_node = pop_barrier_node
123
+ collapse_sources.each do |source|
124
+ definition[:edges][source][:barrier_node] = barrier_node
125
+ end
126
+
127
+ self
128
+ end
129
+
130
+ def divert(to:) # rubocop:todo Metrics/AbcSize
131
+ validate_classes!(to.values)
132
+ validate_definition_started!(action: "diverting chain")
133
+ validate_fallback_step!(to.keys)
134
+
135
+ to_map = {}
136
+ to_nodes = []
137
+ to.each do |key, klass|
138
+ node = node_name(klass)
139
+ definition[:edges][node] ||= { klass: klass.name }
140
+ to_map[key.to_s] = node
141
+ to_nodes << node
142
+ end
143
+
144
+ last_nodes.each do |last_node|
145
+ definition[:edges][last_node][:to] = to_map
146
+ definition[:edges][last_node][:type] = :divert
147
+ end
148
+
149
+ @last_nodes = to_nodes
150
+
151
+ definition[:nodes].push(*to_nodes)
152
+ divergences.push(:divert)
153
+
154
+ if block_given?
155
+ branches = last_nodes.map do |last_node|
156
+ Ductwork::DSL::BranchBuilder
157
+ .new(last_node:, definition:)
158
+ end
159
+
160
+ yield branches
161
+ end
162
+
163
+ self
164
+ end
165
+
166
+ def converge(into:) # rubocop:todo Metrics/AbcSize
167
+ validate_classes!(into)
168
+ validate_definition_started!(action: "converging steps")
169
+ validate_can_converge!
170
+
171
+ divergences.pop
172
+
173
+ into_node = node_name(into)
174
+ definition[:edges][into_node] ||= { klass: into.name }
175
+ last_nodes = definition[:nodes].reverse.select do |node|
176
+ definition.dig(:edges, node, :to).blank?
177
+ end
178
+ last_nodes.each do |last_node|
179
+ definition[:edges][last_node][:to] = [into_node]
180
+ definition[:edges][last_node][:type] = :converge
181
+ end
182
+
183
+ @last_nodes = Array(into_node)
184
+
185
+ definition[:nodes].push(into_node)
111
186
 
112
187
  self
113
188
  end
@@ -128,7 +203,17 @@ module Ductwork
128
203
 
129
204
  private
130
205
 
131
- attr_reader :definition, :last_nodes, :divergences
206
+ attr_reader :definition, :last_nodes, :divergences, :expand_sources
207
+
208
+ def pop_barrier_node
209
+ sources = expand_sources.pop || []
210
+
211
+ if sources.length == 1
212
+ sources.first
213
+ else
214
+ sources
215
+ end
216
+ end
132
217
 
133
218
  def validate_classes!(klasses)
134
219
  valid = Array(klasses).all? do |klass|
@@ -176,6 +261,20 @@ module Ductwork
176
261
  end
177
262
  end
178
263
 
264
+ def validate_can_converge!
265
+ if divergences.empty?
266
+ raise ConvergeError, "Must divert pipeline definition before converging steps"
267
+ elsif divergences[-1] != :divert
268
+ raise ConvergeError, "Ambiguous converge on most recently divided/expanded definition"
269
+ end
270
+ end
271
+
272
+ def validate_fallback_step!(keys)
273
+ if keys.exclude?(:otherwise)
274
+ raise DivertError, "Must specify an `otherwise` branch"
275
+ end
276
+ end
277
+
179
278
  def add_edge_to_last_nodes(*klasses, type:)
180
279
  to_nodes = klasses.map do |klass|
181
280
  node = node_name(klass)
@@ -1,25 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ductwork
4
- class JobClaim
5
- def initialize(klass)
4
+ class ExecutionClaim
5
+ def initialize(klass, owner_process_id)
6
6
  @klass = klass
7
+ @owner_process_id = owner_process_id
7
8
  @adapter = Ductwork::Record.connection.adapter_name.downcase
8
9
  end
9
10
 
10
11
  def latest
11
12
  claim = if supports_row_locking?
12
- RowLockingJobClaim
13
+ RowLockingExecutionClaim
13
14
  else
14
- OptimisticLockingJobClaim
15
+ OptimisticLockingExecutionClaim
15
16
  end
16
17
 
17
- claim.new(klass).latest
18
+ claim.new(klass, owner_process_id).latest
18
19
  end
19
20
 
20
21
  private
21
22
 
22
- attr_reader :klass, :adapter
23
+ attr_reader :klass, :owner_process_id, :adapter
23
24
 
24
25
  def supports_row_locking?
25
26
  adapter.match?(/postgresql/i) ||
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ module FaultInjection
5
+ ENV_KEY = "DUCTWORK_FAULT"
6
+
7
+ class << self
8
+ def checkpoint(key)
9
+ return if !ENV.key?(ENV_KEY)
10
+
11
+ spec = ENV.fetch(ENV_KEY, "").split(":")
12
+
13
+ return if spec[0] != key.to_s
14
+
15
+ warn "FAULT FIRING: #{spec[0]} -> #{spec[1]} (pid=#{::Process.pid})"
16
+
17
+ case spec[1]
18
+ when "kill" then ::Process.kill(:KILL, ::Process.pid)
19
+ when "raise" then raise "FaultInjection: #{key}"
20
+ when "sleep" then sleep(1)
21
+ when "exit" then exit!(1)
22
+ end
23
+ end
24
+
25
+ def with(key, action)
26
+ previous_value = ENV.fetch(ENV_KEY, nil)
27
+ ENV[ENV_KEY] = "#{key}:#{action}"
28
+
29
+ yield
30
+ ensure
31
+ ENV[ENV_KEY] = previous_value
32
+ end
33
+ end
34
+ end
35
+ end
@@ -36,7 +36,7 @@ module Ductwork
36
36
  end
37
37
 
38
38
  def mysql?
39
- connection.adapter_name.match?(/mysql/i)
39
+ connection.adapter_name.match?(/mysql|trilogy/i)
40
40
  end
41
41
  end
42
42
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Advancement < Ductwork::Record
5
+ belongs_to :process, class_name: "Ductwork::Process", optional: true
6
+ belongs_to :transition, class_name: "Ductwork::Transition"
7
+
8
+ validates :started_at, presence: true
9
+
10
+ CRASH_ERROR_KLASSES = %w[Ductwork::ProcessCrash Ductwork::ThreadCrash].freeze
11
+
12
+ def crash?
13
+ CRASH_ERROR_KLASSES.include?(error_klass)
14
+ end
15
+
16
+ def process_crashed!
17
+ Ductwork::Record.transaction do
18
+ rows_updated = self.class
19
+ .where(id: id, completed_at: nil)
20
+ .update_all(
21
+ completed_at: Time.current,
22
+ error_klass: "Ductwork::ProcessCrash",
23
+ error_message: "Reaped from orphaned process"
24
+ )
25
+
26
+ return if rows_updated.zero?
27
+
28
+ transition.branch.release!
29
+ end
30
+ end
31
+
32
+ def thread_crashed!(expected_token)
33
+ Ductwork::Record.transaction do
34
+ rows_updated = self.class
35
+ .where(id: id, completed_at: nil)
36
+ .update_all(
37
+ completed_at: Time.current,
38
+ error_klass: "Ductwork::ThreadCrash",
39
+ error_message: "Advancement abandoned from a thread crash"
40
+ )
41
+
42
+ return if rows_updated.zero?
43
+
44
+ transition.branch.release!(expected_token)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class Attempt < Ductwork::Record
5
+ belongs_to :execution, class_name: "Ductwork::Execution"
6
+
7
+ validates :started_at, presence: true
8
+ end
9
+ end
@@ -3,7 +3,9 @@
3
3
  module Ductwork
4
4
  class Availability < Ductwork::Record
5
5
  belongs_to :execution, class_name: "Ductwork::Execution"
6
+ belongs_to :process, class_name: "Ductwork::Process", optional: true
6
7
 
7
8
  validates :started_at, presence: true
9
+ validates :pipeline_klass, presence: true
8
10
  end
9
11
  end