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 +4 -4
- data/CHANGELOG.md +29 -0
- data/app/models/good_pipeline/pipeline_record.rb +4 -1
- data/app/models/good_pipeline/step_record.rb +4 -1
- data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +1 -0
- data/demo/test/test_helper.rb +1 -0
- data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +1 -0
- data/lib/good_pipeline/constants.rb +6 -0
- data/lib/good_pipeline/coordinator.rb +123 -69
- data/lib/good_pipeline/graph_validator.rb +13 -26
- data/lib/good_pipeline/pipeline.rb +4 -3
- data/lib/good_pipeline/runner.rb +64 -44
- data/lib/good_pipeline/step_definition.rb +24 -4
- data/lib/good_pipeline/version.rb +1 -1
- data/lib/good_pipeline.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 350a7e051f704db8ee906a90bb8f641f1373be815aceb9adccc8cd17b2d38640
|
|
4
|
+
data.tar.gz: f0cebfb8f77d35e043df87e71e5698b4e6ec08a468d95702a5a3a489eb1debf9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/demo/test/test_helper.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
111
|
-
step.
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
216
|
+
enqueued_job.provider_job_id || enqueued_job.job_id
|
|
129
217
|
end
|
|
130
218
|
|
|
131
|
-
def derive_terminal_status(
|
|
132
|
-
has_failures = steps.
|
|
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
|
-
|
|
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
|
|
29
|
-
|
|
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
|
|
32
|
+
raise InvalidPipelineError, "duplicate step key :#{step.key}" if @steps_by_key.key?(step.key)
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
end
|
|
37
|
+
@forward_edges[dependency_key] << step.key
|
|
38
|
+
end
|
|
40
39
|
|
|
41
|
-
|
|
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,
|
|
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:
|
|
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
|
data/lib/good_pipeline/runner.rb
CHANGED
|
@@ -10,61 +10,81 @@ module GoodPipeline
|
|
|
10
10
|
@pipeline = pipeline_instance
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def call(start: true) # rubocop:disable Metrics/
|
|
13
|
+
def call(start: true) # rubocop:disable Metrics/MethodLength
|
|
14
|
+
pipeline_id = SecureRandom.uuid
|
|
14
15
|
pipeline_record = nil
|
|
15
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
80
|
+
DependencyRecord.insert_all!(dependency_rows) if dependency_rows.any?
|
|
65
81
|
end
|
|
66
82
|
|
|
67
|
-
|
|
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,
|
|
8
|
-
:
|
|
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(
|
|
11
|
-
|
|
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
|
|
data/lib/good_pipeline.rb
CHANGED
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.
|
|
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
|