good_pipeline 0.2.2 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e52af813bd86f0de3d905a3f591209e0a155e0b5c28e3a76f963f1c2cdbd8232
4
- data.tar.gz: 3a48213db593949e7f9afff0f341cf29dfdb90d4972b20b4c5e48091cb86bab1
3
+ metadata.gz: 350a7e051f704db8ee906a90bb8f641f1373be815aceb9adccc8cd17b2d38640
4
+ data.tar.gz: f0cebfb8f77d35e043df87e71e5698b4e6ec08a468d95702a5a3a489eb1debf9
5
5
  SHA512:
6
- metadata.gz: 7bc3d7e1181b852bbc795f1e65ef9b2764645ffc13d7d860f444074158c741202ddae188253c50cb12a44da9958fe6403b2435fef6ec71d9172a35b46816da55
7
- data.tar.gz: 22b59ba7dba841a6d55559c27a7427032bad94ed1fd3e336f2d53997ee4a24bf2f8c193d77e743095e4c5c73b948975b0f85228d63839eba367af1dc441f8456
6
+ metadata.gz: 682725126bc0643cd8ec88d249ef7e93b760d646a4aac30cbb693c1bde6d1c0b3d30494623eb5ffed8cc2845f62dc3642501648bf65cbf3adc43577f13734270
7
+ data.tar.gz: 5e2aae2df9e5b8997b85a4aff15d0989fa24f27b6e607332d82421c5e5b33d0a4171e455ddc04eab71f790657fe9f71d59b412b7ffa054b7f15c60148b1a1536
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-03-25
4
+
5
+ ### Performance
6
+
7
+ - **Bulk insert steps and dependencies** — `Runner` uses `insert_all!` with `RETURNING` for steps and dependencies instead of individual `create!` calls, reducing pipeline creation from N+M queries to 2.
8
+ - **Pre-generated pipeline UUID** — `Runner` generates the pipeline UUID upfront, folding batch ID and initial status into a single INSERT instead of separate UPDATEs.
9
+ - **Atomic upstream counter** — new `pending_upstream_count` column on steps tracks how many upstreams remain. `unblock_downstream_steps` atomically decrements via `UPDATE ... RETURNING` and only calls `try_enqueue_step` when the count reaches zero, eliminating O(N) wasted lock acquisitions for fan-in and diamond topologies.
10
+ - **Merged UPDATE round-trips** — `enqueue_user_job` folds status transition, batch ID, and job ID into one `update_columns`. `record_step_failure` merges status and error metadata into one `update_columns`.
11
+ - **Removed redundant transaction** — `record_step_outcome` no longer wraps a single `update_columns` in an explicit transaction.
12
+ - **`update_columns` in transition methods** — `transition_coordination_status_to!` and `transition_to!` use `update_columns` instead of `update!`, skipping AR dirty tracking overhead.
13
+ - **SQL EXISTS for status checks** — `recompute_pipeline_status` and `derive_terminal_status` use `EXISTS` queries instead of loading all step records.
14
+ - **Pipeline load with EXISTS** — `load_pipeline_with_active_check` combines pipeline load with active-step and downstream-chain EXISTS checks in a single query.
15
+ - **Conditional callback dispatch** — `dispatch_callbacks_once` uses `UPDATE WHERE callbacks_dispatched_at IS NULL` instead of `SELECT FOR UPDATE` + `UPDATE`.
16
+ - **Early return on active pipeline** — `complete_step` skips pipeline status recomputation when `unblock_downstream_steps` enqueued any downstream step.
17
+ - **Bulk skip on halt** — `skip_all_pending_steps` uses `update_all` instead of iterating with individual updates.
18
+ - **Single-pass graph validation** — `GraphValidator` merges duplicate-key check, self-dependency check, steps-by-key index, and forward-edges construction into one O(n) pass and returns `steps_by_key` for reuse by `Pipeline`.
19
+ - **Frozen constant defaults** — `EMPTY_HASH` and `EMPTY_ARRAY` shared constants avoid allocating fresh empty containers on every `StepDefinition` and `Pipeline#run` call.
20
+ - **Fast-path shortcuts** — `validate_enqueue_options!` returns immediately for empty options. `expand_branch_aliases` skips `flat_map` when no branches are defined.
21
+
22
+ ### Added
23
+
24
+ - **Benchmarking scripts** — `bench/memory_bench.rb` (in-memory, no DB) and `bench/database_bench.rb` (PostgreSQL) with `--json` flag for structured output. Covers pipeline construction, graph validation, cycle detection, step enqueue, step completion, status recomputation, halt propagation, and full pipeline run across linear, fan-out, fan-in, and diamond topologies.
25
+ - **`pending_upstream_count` column** — integer column on steps table, set by `Runner` at creation time, decremented atomically by `Coordinator` on step completion.
26
+
27
+ ### Changed
28
+
29
+ - **`Runner` refactored** — `call` method extracted into `create_pipeline_batch`, `create_pipeline_record`, `insert_steps`, `insert_dependencies`, and `enqueue_root_steps` for readability. Pipeline record is a local variable passed to methods instead of an instance variable.
30
+ - **`Coordinator` method reordering** — private methods grouped by concern (outcome recording, downstream unblocking, step resolution, pipeline status) rather than call order.
31
+
3
32
  ## [0.2.2] - 2026-03-24
4
33
 
5
34
  ### Fixed
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GoodPipeline
4
+ # This model intentionally has no AR callbacks or validations. Status transitions
5
+ # use update_columns throughout the coordinator layer. If you need lifecycle hooks,
6
+ # ensure all update_columns call sites are updated accordingly.
4
7
  class PipelineRecord < ActiveRecord::Base
5
8
  self.table_name = "good_pipeline_pipelines"
6
9
  self.inheritance_column = nil
@@ -67,7 +70,7 @@ module GoodPipeline
67
70
  raise InvalidTransition, "cannot transition pipeline from '#{status}' to '#{new_status}'"
68
71
  end
69
72
 
70
- update!(status: new_status)
73
+ update_columns(status: new_status, updated_at: Time.current)
71
74
  end
72
75
  end
73
76
  end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GoodPipeline
4
+ # This model intentionally has no AR callbacks or validations. Status transitions
5
+ # use update_columns throughout the coordinator layer. If you need lifecycle hooks,
6
+ # ensure all update_columns call sites are updated accordingly.
4
7
  class StepRecord < ActiveRecord::Base
5
8
  self.table_name = "good_pipeline_steps"
6
9
 
@@ -74,7 +77,7 @@ module GoodPipeline
74
77
  "cannot transition step '#{key}' coordination_status from '#{coordination_status}' to '#{new_status}'"
75
78
  end
76
79
 
77
- update!(coordination_status: new_status)
80
+ update_columns(coordination_status: new_status, updated_at: Time.current)
78
81
  end
79
82
  end
80
83
  end
@@ -30,6 +30,7 @@ class CreateGoodPipelineTables < ActiveRecord::Migration[8.1]
30
30
  t.jsonb :branch, null: false, default: {}
31
31
  t.uuid :good_job_batch_id
32
32
  t.uuid :good_job_id
33
+ t.integer :pending_upstream_count, null: false, default: 0
33
34
  t.integer :attempts
34
35
  t.string :error_class
35
36
  t.text :error_message
@@ -65,6 +65,7 @@ module ActiveSupport
65
65
  dependencies.each do |dependency_step|
66
66
  GoodPipeline::DependencyRecord.create!(pipeline: pipeline, step: step, depends_on_step: dependency_step)
67
67
  end
68
+ step.update_column(:pending_upstream_count, dependencies.size)
68
69
  step
69
70
  end
70
71
  end
@@ -28,6 +28,7 @@ class CreateGoodPipelineTables < ActiveRecord::Migration[<%= ActiveRecord::Migra
28
28
  t.jsonb :branch, null: false, default: {}
29
29
  t.uuid :good_job_batch_id
30
30
  t.uuid :good_job_id
31
+ t.integer :pending_upstream_count, null: false, default: 0
31
32
  t.integer :attempts
32
33
  t.string :error_class
33
34
  t.text :error_message
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ EMPTY_HASH = {}.freeze
5
+ EMPTY_ARRAY = [].freeze
6
+ end
@@ -8,20 +8,29 @@ module GoodPipeline
8
8
 
9
9
  record_step_outcome(step, succeeded)
10
10
  propagate_halt(step) if !succeeded && step.pipeline.halt?
11
- unblock_downstream_steps(step)
12
- recompute_pipeline_status(step.pipeline.reload)
11
+ return if unblock_downstream_steps(step)
12
+
13
+ pipeline = load_pipeline_with_active_check(step.pipeline_id)
14
+
15
+ recompute_pipeline_status(
16
+ pipeline,
17
+ has_active_steps: pipeline["has_active_steps"],
18
+ has_downstream_chains: pipeline["has_downstream_chains"]
19
+ )
13
20
  end
14
21
 
15
22
  def try_enqueue_step(step_id) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
23
+ step_was_enqueued = false
16
24
  skipped_downstream_ids = nil
17
25
  recompute_pipeline = nil
18
26
 
19
27
  StepRecord.transaction do
20
28
  locked_step = StepRecord.lock("FOR UPDATE").find_by(id: step_id)
21
- return unless locked_step&.pending?
22
- return if locked_step.good_job_id.present?
29
+ return false unless locked_step&.pending?
30
+ return false if locked_step.good_job_id.present?
23
31
 
24
32
  skipped_downstream_ids = resolve_step(locked_step)
33
+ step_was_enqueued = skipped_downstream_ids.nil?
25
34
  rescue ConfigurationError => error
26
35
  fail_step_with_error(locked_step, error)
27
36
  propagate_halt(locked_step) if locked_step.pipeline.halt?
@@ -29,48 +38,55 @@ module GoodPipeline
29
38
  recompute_pipeline = locked_step.pipeline
30
39
  end
31
40
 
32
- skipped_downstream_ids&.each { |downstream_step_id| try_enqueue_step(downstream_step_id) }
41
+ downstream_enqueued = false
42
+ skipped_downstream_ids&.each { |id| downstream_enqueued = true if try_enqueue_step(id) }
33
43
  recompute_pipeline_status(recompute_pipeline.reload) if recompute_pipeline
44
+ step_was_enqueued || downstream_enqueued
34
45
  end
35
46
 
36
- def recompute_pipeline_status(pipeline)
37
- steps = pipeline.steps.reload
38
-
39
- return if steps.any? { |step| step.pending? || step.enqueued? }
47
+ def recompute_pipeline_status(pipeline, has_active_steps: nil, has_downstream_chains: nil) # rubocop:disable Metrics/MethodLength
40
48
  return if pipeline.terminal?
41
49
 
42
- new_status = derive_terminal_status(steps, pipeline)
50
+ active = if has_active_steps.nil?
51
+ pipeline.steps.where(coordination_status: %w[pending enqueued]).exists?
52
+ else
53
+ has_active_steps
54
+ end
55
+
56
+ return if active
57
+
58
+ new_status = derive_terminal_status(pipeline)
43
59
  pipeline.transition_to!(new_status)
44
60
  dispatch_callbacks_once(pipeline, new_status)
45
- ChainCoordinator.propagate_terminal_state(pipeline)
61
+ ChainCoordinator.propagate_terminal_state(pipeline) unless has_downstream_chains == false
46
62
  end
47
63
 
48
64
  def dispatch_callbacks_once(pipeline, new_status)
49
65
  PipelineRecord.transaction do
50
- locked = PipelineRecord.lock("FOR UPDATE").find(pipeline.id)
51
- return if locked.callbacks_dispatched_at.present?
66
+ rows_updated = PipelineRecord.where(id: pipeline.id, callbacks_dispatched_at: nil)
67
+ .update_all(callbacks_dispatched_at: Time.current)
68
+
69
+ return if rows_updated.zero?
52
70
 
53
- locked.update!(callbacks_dispatched_at: Time.current)
54
- PipelineCallbackJob.perform_later(locked.id, new_status.to_s)
71
+ PipelineCallbackJob.perform_later(pipeline.id, new_status.to_s)
55
72
  end
56
73
  end
57
74
 
58
75
  private
59
76
 
60
77
  def record_step_outcome(step, succeeded)
61
- StepRecord.transaction do
62
- if succeeded
63
- step.transition_coordination_status_to!(:succeeded)
64
- else
65
- record_step_failure(step)
66
- end
78
+ if succeeded
79
+ step.transition_coordination_status_to!(:succeeded)
80
+ else
81
+ record_step_failure(step)
67
82
  end
68
83
  end
69
84
 
70
85
  def record_step_failure(step)
71
86
  metadata = FailureMetadata.extract(step)
72
- step.transition_coordination_status_to!(:failed)
73
87
  step.update_columns(
88
+ coordination_status: "failed",
89
+ updated_at: Time.current,
74
90
  error_class: metadata.error_class,
75
91
  error_message: metadata.error_message,
76
92
  attempts: metadata.attempts
@@ -78,28 +94,71 @@ module GoodPipeline
78
94
  end
79
95
 
80
96
  def propagate_halt(step)
81
- pipeline = step.pipeline
82
97
  StepRecord.transaction do
83
- pipeline.update_column(:halt_triggered, true)
84
- skip_all_pending_steps(pipeline, except_dependents_of: step)
98
+ step.pipeline.update_column(:halt_triggered, true)
99
+ skip_all_pending_steps(step.pipeline, except_dependents_of: step)
100
+ end
101
+ end
102
+
103
+ def skip_all_pending_steps(pipeline, except_dependents_of:)
104
+ scope = pipeline.steps.pending
105
+
106
+ if effective_failure_strategy(except_dependents_of) == :ignore
107
+ exempt_ids = transitive_downstream_ids(except_dependents_of)
108
+ scope = scope.where.not(id: exempt_ids.to_a) if exempt_ids.any?
85
109
  end
110
+
111
+ scope.update_all(coordination_status: "skipped")
86
112
  end
87
113
 
88
114
  def unblock_downstream_steps(step)
89
- step.downstream_steps.each do |downstream_step|
90
- try_enqueue_step(downstream_step.id)
115
+ sql = <<~SQL
116
+ UPDATE good_pipeline_steps
117
+ SET pending_upstream_count = pending_upstream_count - 1
118
+ WHERE id IN (
119
+ SELECT step_id FROM good_pipeline_dependencies
120
+ WHERE depends_on_step_id = $1
121
+ )
122
+ AND coordination_status = 'pending'
123
+ RETURNING id, pending_upstream_count
124
+ SQL
125
+
126
+ any_enqueued = false
127
+ StepRecord.connection.exec_query(sql, "SQL", [step.id]).each do |row|
128
+ any_enqueued = true if row["pending_upstream_count"].zero? && try_enqueue_step(row["id"])
91
129
  end
130
+ any_enqueued
92
131
  end
93
132
 
94
- def resolve_step(locked_step) # rubocop:disable Metrics/MethodLength
133
+ def load_pipeline_with_active_check(pipeline_id)
134
+ sql = <<~SQL.squish
135
+ good_pipeline_pipelines.*,
136
+ EXISTS(
137
+ SELECT 1 FROM good_pipeline_steps
138
+ WHERE pipeline_id = good_pipeline_pipelines.id
139
+ AND coordination_status IN ('pending', 'enqueued')
140
+ ) AS has_active_steps,
141
+ EXISTS(
142
+ SELECT 1 FROM good_pipeline_chains
143
+ WHERE upstream_pipeline_id = good_pipeline_pipelines.id
144
+ ) AS has_downstream_chains
145
+ SQL
146
+
147
+ PipelineRecord.select(sql).where(id: pipeline_id).first!
148
+ end
149
+
150
+ def resolve_step(locked_step) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
95
151
  if should_skip?(locked_step)
96
152
  locked_step.transition_coordination_status_to!(:skipped)
153
+ decrement_upstream_counts_for_terminal_step(locked_step.id)
97
154
  locked_step.downstream_steps.pluck(:id)
98
155
  elsif locked_step.branch_step? && all_upstreams_satisfied?(locked_step)
99
156
  BranchResolver.resolve(locked_step)
157
+ decrement_upstream_counts_for_terminal_step(locked_step.id)
100
158
  locked_step.downstream_steps.pluck(:id)
101
159
  elsif BranchResolver.skipped_by_branch?(locked_step)
102
160
  locked_step.transition_coordination_status_to!(:skipped_by_branch)
161
+ decrement_upstream_counts_for_terminal_step(locked_step.id)
103
162
  locked_step.downstream_steps.pluck(:id)
104
163
  else
105
164
  enqueue_user_job(locked_step) if all_upstreams_satisfied?(locked_step)
@@ -107,12 +166,41 @@ module GoodPipeline
107
166
  end
108
167
  end
109
168
 
110
- def enqueue_user_job(step)
111
- step.transition_coordination_status_to!(:enqueued)
169
+ def should_skip?(step)
170
+ step.pending? && step.upstream_steps.any? { |upstream| permanently_unsatisfied?(upstream) }
171
+ end
172
+
173
+ def permanently_unsatisfied?(upstream)
174
+ upstream.terminal_coordination_status? &&
175
+ !upstream.succeeded? &&
176
+ !upstream.skipped_by_branch? &&
177
+ effective_failure_strategy(upstream) != :ignore
178
+ end
179
+
180
+ def decrement_upstream_counts_for_terminal_step(step_id)
181
+ downstream_ids = DependencyRecord.where(depends_on_step_id: step_id).select(:step_id)
182
+ StepRecord.where(id: downstream_ids, coordination_status: "pending")
183
+ .update_all("pending_upstream_count = pending_upstream_count - 1")
184
+ end
185
+
186
+ def all_upstreams_satisfied?(step)
187
+ step.upstream_steps.all? do |upstream|
188
+ upstream.succeeded? ||
189
+ upstream.skipped_by_branch? ||
190
+ (upstream.failed? && effective_failure_strategy(upstream) == :ignore)
191
+ end
192
+ end
112
193
 
194
+ def enqueue_user_job(step)
113
195
  batch = build_step_batch(step)
114
- batch.enqueue { enqueue_step_job(step) }
115
- step.update_column(:good_job_batch_id, batch.id)
196
+ good_job_id = nil
197
+ batch.enqueue { good_job_id = enqueue_step_job(step) }
198
+ step.update_columns(
199
+ coordination_status: "enqueued",
200
+ good_job_batch_id: batch.id,
201
+ good_job_id: good_job_id,
202
+ updated_at: Time.current
203
+ )
116
204
  end
117
205
 
118
206
  def build_step_batch(step)
@@ -125,11 +213,11 @@ module GoodPipeline
125
213
  def enqueue_step_job(step)
126
214
  job = step.job_class.constantize.new(**step.params.symbolize_keys)
127
215
  enqueued_job = job.enqueue(**step.enqueue_options.symbolize_keys)
128
- step.update_column(:good_job_id, enqueued_job.provider_job_id || enqueued_job.job_id)
216
+ enqueued_job.provider_job_id || enqueued_job.job_id
129
217
  end
130
218
 
131
- def derive_terminal_status(steps, pipeline)
132
- has_failures = steps.any?(&:failed?)
219
+ def derive_terminal_status(pipeline)
220
+ has_failures = pipeline.steps.where(coordination_status: "failed").exists?
133
221
 
134
222
  return :succeeded unless has_failures
135
223
  return :halted if pipeline.halt_triggered?
@@ -137,40 +225,6 @@ module GoodPipeline
137
225
  :failed
138
226
  end
139
227
 
140
- def all_upstreams_satisfied?(step)
141
- step.upstream_steps.all? do |upstream|
142
- upstream.succeeded? ||
143
- upstream.skipped_by_branch? ||
144
- (upstream.failed? && effective_failure_strategy(upstream) == :ignore)
145
- end
146
- end
147
-
148
- def should_skip?(step)
149
- step.pending? &&
150
- step.upstream_steps.any? { |upstream| permanently_unsatisfied?(upstream) }
151
- end
152
-
153
- def permanently_unsatisfied?(upstream)
154
- upstream.terminal_coordination_status? &&
155
- !upstream.succeeded? &&
156
- !upstream.skipped_by_branch? &&
157
- effective_failure_strategy(upstream) != :ignore
158
- end
159
-
160
- def skip_all_pending_steps(pipeline, except_dependents_of:)
161
- exempt_step_ids = if effective_failure_strategy(except_dependents_of) == :ignore
162
- transitive_downstream_ids(except_dependents_of)
163
- else
164
- Set.new
165
- end
166
-
167
- pipeline.steps.pending.find_each do |pending_step|
168
- next if exempt_step_ids.include?(pending_step.id)
169
-
170
- pending_step.transition_coordination_status_to!(:skipped)
171
- end
172
- end
173
-
174
228
  def transitive_downstream_ids(step)
175
229
  visited = Set.new
176
230
  queue = step.downstream_steps.pluck(:id)
@@ -12,11 +12,10 @@ module GoodPipeline
12
12
 
13
13
  def validate!
14
14
  check_empty_pipeline!
15
- check_duplicate_keys!
16
- build_steps_by_key!
17
- check_self_dependencies!
15
+ build_index!
18
16
  check_unknown_references!
19
17
  check_cycles!
18
+ @steps_by_key
20
19
  end
21
20
 
22
21
  private
@@ -25,22 +24,20 @@ module GoodPipeline
25
24
  raise InvalidPipelineError, "pipeline has no steps" if @step_definitions.empty?
26
25
  end
27
26
 
28
- def check_duplicate_keys!
29
- seen = {}
27
+ def build_index! # rubocop:disable Metrics/AbcSize
28
+ @steps_by_key = {}
29
+ @forward_edges = Hash.new { |h, k| h[k] = [] }
30
+
30
31
  @step_definitions.each do |step|
31
- raise InvalidPipelineError, "duplicate step key :#{step.key}" if seen.key?(step.key)
32
+ raise InvalidPipelineError, "duplicate step key :#{step.key}" if @steps_by_key.key?(step.key)
32
33
 
33
- seen[step.key] = true
34
- end
35
- end
34
+ step.dependencies.each do |dependency_key|
35
+ raise InvalidPipelineError, "step :#{step.key} depends on itself" if dependency_key == step.key
36
36
 
37
- def build_steps_by_key!
38
- @steps_by_key = @step_definitions.to_h { |step| [step.key, step] }
39
- end
37
+ @forward_edges[dependency_key] << step.key
38
+ end
40
39
 
41
- def check_self_dependencies!
42
- @steps_by_key.each_value do |step|
43
- raise InvalidPipelineError, "step :#{step.key} depends on itself" if step.dependencies.include?(step.key)
40
+ @steps_by_key[step.key] = step
44
41
  end
45
42
  end
46
43
 
@@ -55,17 +52,7 @@ module GoodPipeline
55
52
  end
56
53
 
57
54
  def check_cycles!
58
- CycleDetector.check!(@steps_by_key, build_forward_edges)
59
- end
60
-
61
- def build_forward_edges
62
- edges = Hash.new { |h, k| h[k] = [] }
63
- @steps_by_key.each_value do |step|
64
- step.dependencies.each do |dependency_key|
65
- edges[dependency_key] << step.key
66
- end
67
- end
68
- edges
55
+ CycleDetector.check!(@steps_by_key, @forward_edges)
69
56
  end
70
57
  end
71
58
  end
@@ -103,11 +103,10 @@ module GoodPipeline
103
103
  @branch_context_stack = []
104
104
  @building = true
105
105
  configure(**kwargs)
106
- GraphValidator.validate!(@step_definitions)
106
+ @steps_by_key = GraphValidator.validate!(@step_definitions).freeze
107
107
  @step_definitions.freeze
108
108
  @branch_aliases.freeze
109
109
  @building = false
110
- @steps_by_key = @step_definitions.to_h { |step| [step.key, step] }.freeze
111
110
  @root_steps = @step_definitions.select { |step| step.dependencies.empty? }.freeze
112
111
  freeze
113
112
  end
@@ -118,7 +117,7 @@ module GoodPipeline
118
117
  raise NotImplementedError, "#{self.class} must implement #configure"
119
118
  end
120
119
 
121
- def run(key, job_class, with: {}, after: [], on_failure: nil, enqueue: {}) # rubocop:disable Metrics/MethodLength
120
+ def run(key, job_class, with: EMPTY_HASH, after: EMPTY_ARRAY, on_failure: nil, enqueue: EMPTY_HASH) # rubocop:disable Metrics/MethodLength
122
121
  raise ConfigurationError, "run can only be called inside configure" unless @building
123
122
 
124
123
  expanded_after = expand_branch_aliases(after)
@@ -186,6 +185,8 @@ module GoodPipeline
186
185
  # NOTE: Single-level expansion only. If nested branches are added in the future,
187
186
  # this must become recursive to expand inner branch aliases.
188
187
  def expand_branch_aliases(dependencies)
188
+ return Array(dependencies) if @branch_aliases.empty?
189
+
189
190
  Array(dependencies).flat_map { |dependency| @branch_aliases.fetch(dependency, [dependency]) }
190
191
  end
191
192
  end
@@ -10,61 +10,81 @@ module GoodPipeline
10
10
  @pipeline = pipeline_instance
11
11
  end
12
12
 
13
- def call(start: true) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
13
+ def call(start: true) # rubocop:disable Metrics/MethodLength
14
+ pipeline_id = SecureRandom.uuid
14
15
  pipeline_record = nil
15
- step_records = {}
16
-
17
- PipelineRecord.transaction do # rubocop:disable Metrics/BlockLength
18
- pipeline_record = PipelineRecord.create!(
19
- type: @pipeline.class.name,
20
- params: @pipeline.params,
21
- status: :pending,
22
- on_failure_strategy: @pipeline.failure_strategy.to_s
23
- )
24
-
25
- # Two passes: create all step records first, then dependencies.
26
- # Branch steps may appear after their dependents in step_definitions.
27
- @pipeline.step_definitions.each do |step_definition|
28
- step_records[step_definition.key] = StepRecord.create!(
29
- pipeline: pipeline_record,
30
- key: step_definition.key.to_s,
31
- job_class: resolve_job_class(step_definition),
32
- params: step_definition.params,
33
- on_failure_strategy: step_definition.failure_strategy&.to_s,
34
- enqueue_options: step_definition.enqueue_options,
35
- branch: build_branch_hash(step_definition)
36
- )
37
- end
16
+ step_id_by_key = {}
38
17
 
39
- @pipeline.step_definitions.each do |step_definition| # rubocop:disable Style/CombinableLoops
40
- step_definition.dependencies.each do |dependency_key|
41
- DependencyRecord.create!(
42
- pipeline: pipeline_record,
43
- step: step_records[step_definition.key],
44
- depends_on_step: step_records[dependency_key]
45
- )
46
- end
47
- end
18
+ PipelineRecord.transaction do
19
+ batch = create_pipeline_batch(pipeline_id)
20
+ pipeline_record = create_pipeline_record(pipeline_id, batch.id, start: start)
21
+ step_id_by_key = insert_steps(pipeline_record)
22
+ insert_dependencies(pipeline_record, step_id_by_key)
23
+ end
24
+
25
+ enqueue_root_steps(step_id_by_key) if start
26
+
27
+ pipeline_record
28
+ end
29
+
30
+ private
48
31
 
49
- pipeline_batch = GoodJob::Batch.new
50
- pipeline_batch.on_finish = "GoodPipeline::PipelineReconciliationJob"
51
- pipeline_batch.properties = { pipeline_id: pipeline_record.id }
52
- pipeline_batch.save
53
- pipeline_record.update_column(:good_job_batch_id, pipeline_batch.id)
32
+ def create_pipeline_batch(pipeline_id)
33
+ batch = GoodJob::Batch.new
34
+ batch.on_finish = "GoodPipeline::PipelineReconciliationJob"
35
+ batch.properties = { pipeline_id: pipeline_id }
36
+ batch.save
37
+ batch
38
+ end
39
+
40
+ def create_pipeline_record(pipeline_id, batch_id, start:)
41
+ PipelineRecord.create!(
42
+ id: pipeline_id,
43
+ type: @pipeline.class.name,
44
+ params: @pipeline.params,
45
+ status: start ? :running : :pending,
46
+ on_failure_strategy: @pipeline.failure_strategy.to_s,
47
+ good_job_batch_id: batch_id
48
+ )
49
+ end
54
50
 
55
- pipeline_record.transition_to!(:running) if start
51
+ def insert_steps(pipeline_record) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
52
+ step_rows = @pipeline.step_definitions.map do |step_definition|
53
+ {
54
+ pipeline_id: pipeline_record.id,
55
+ key: step_definition.key.to_s,
56
+ job_class: resolve_job_class(step_definition),
57
+ params: step_definition.params,
58
+ on_failure_strategy: step_definition.failure_strategy&.to_s,
59
+ enqueue_options: step_definition.enqueue_options,
60
+ branch: build_branch_hash(step_definition),
61
+ pending_upstream_count: step_definition.dependencies.size
62
+ }
56
63
  end
57
64
 
58
- if start
59
- @pipeline.root_steps.each do |step_definition|
60
- Coordinator.try_enqueue_step(step_records[step_definition.key].id)
65
+ result = StepRecord.insert_all!(step_rows, returning: %w[id key])
66
+ result.rows.each_with_object({}) { |(id, key), hash| hash[key.to_sym] = id }
67
+ end
68
+
69
+ def insert_dependencies(pipeline_record, step_id_by_key)
70
+ dependency_rows = @pipeline.step_definitions.flat_map do |step_definition|
71
+ step_definition.dependencies.map do |dependency_key|
72
+ {
73
+ pipeline_id: pipeline_record.id,
74
+ step_id: step_id_by_key[step_definition.key],
75
+ depends_on_step_id: step_id_by_key[dependency_key]
76
+ }
61
77
  end
62
78
  end
63
79
 
64
- pipeline_record
80
+ DependencyRecord.insert_all!(dependency_rows) if dependency_rows.any?
65
81
  end
66
82
 
67
- private
83
+ def enqueue_root_steps(step_id_by_key)
84
+ @pipeline.root_steps.each do |step_definition|
85
+ Coordinator.try_enqueue_step(step_id_by_key[step_definition.key])
86
+ end
87
+ end
68
88
 
69
89
  def resolve_job_class(step_definition)
70
90
  step_definition.job_class.is_a?(String) ? step_definition.job_class : step_definition.job_class.name
@@ -4,11 +4,29 @@ module GoodPipeline
4
4
  class StepDefinition
5
5
  SUPPORTED_ENQUEUE_OPTIONS = %i[queue priority wait good_job_labels good_job_notify].freeze
6
6
 
7
- attr_reader :key, :job_class, :params, :dependencies, :failure_strategy, :enqueue_options,
8
- :branch_key, :branch_arm, :decides, :empty_arms
7
+ attr_reader :key,
8
+ :job_class,
9
+ :params,
10
+ :dependencies,
11
+ :failure_strategy,
12
+ :enqueue_options,
13
+ :branch_key,
14
+ :branch_arm,
15
+ :decides,
16
+ :empty_arms
9
17
 
10
- def initialize(key:, job_class:, params: {}, dependencies: [], failure_strategy: nil, enqueue_options: {}, # rubocop:disable Metrics/MethodLength
11
- branch_key: nil, branch_arm: nil, decides: nil, empty_arms: [])
18
+ def initialize( # rubocop:disable Metrics/MethodLength
19
+ key:,
20
+ job_class:,
21
+ params: EMPTY_HASH,
22
+ dependencies: EMPTY_ARRAY,
23
+ failure_strategy: nil,
24
+ enqueue_options: EMPTY_HASH,
25
+ branch_key: nil,
26
+ branch_arm: nil,
27
+ decides: nil,
28
+ empty_arms: EMPTY_ARRAY
29
+ )
12
30
  @key = key
13
31
  @job_class = job_class
14
32
  @params = params.freeze
@@ -37,6 +55,8 @@ module GoodPipeline
37
55
  end
38
56
 
39
57
  def validate_enqueue_options!(options)
58
+ return if options.empty?
59
+
40
60
  unsupported = options.keys.map(&:to_sym) - SUPPORTED_ENQUEUE_OPTIONS
41
61
  return if unsupported.empty?
42
62
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GoodPipeline
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/good_pipeline.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "good_pipeline/version"
4
+ require_relative "good_pipeline/constants"
4
5
  require_relative "good_pipeline/errors"
5
6
  require_relative "good_pipeline/step_definition"
6
7
  require_relative "good_pipeline/branch_builder"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_pipeline
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ali Hamdi Ali Fadel
@@ -177,6 +177,7 @@ files:
177
177
  - lib/good_pipeline/branch_resolver.rb
178
178
  - lib/good_pipeline/chain.rb
179
179
  - lib/good_pipeline/chain_coordinator.rb
180
+ - lib/good_pipeline/constants.rb
180
181
  - lib/good_pipeline/coordinator.rb
181
182
  - lib/good_pipeline/cycle_detector.rb
182
183
  - lib/good_pipeline/engine.rb