good_pipeline 0.1.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 +7 -0
- data/CHANGELOG.md +16 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +217 -0
- data/Rakefile +20 -0
- data/app/controllers/good_pipeline/application_controller.rb +9 -0
- data/app/controllers/good_pipeline/frontends_controller.rb +31 -0
- data/app/controllers/good_pipeline/pipelines_controller.rb +57 -0
- data/app/frontend/good_pipeline/style.css +518 -0
- data/app/helpers/good_pipeline/pipelines_helper.rb +119 -0
- data/app/jobs/good_pipeline/pipeline_callback_job.rb +52 -0
- data/app/jobs/good_pipeline/pipeline_reconciliation_job.rb +10 -0
- data/app/jobs/good_pipeline/step_finished_job.rb +10 -0
- data/app/models/good_pipeline/chain_record.rb +18 -0
- data/app/models/good_pipeline/dependency_record.rb +23 -0
- data/app/models/good_pipeline/pipeline_record.rb +73 -0
- data/app/models/good_pipeline/step_record.rb +74 -0
- data/app/views/good_pipeline/pipelines/_chain_links.html.erb +30 -0
- data/app/views/good_pipeline/pipelines/_pagination.html.erb +24 -0
- data/app/views/good_pipeline/pipelines/_pipeline_row.html.erb +7 -0
- data/app/views/good_pipeline/pipelines/_steps_table.html.erb +33 -0
- data/app/views/good_pipeline/pipelines/definitions.html.erb +49 -0
- data/app/views/good_pipeline/pipelines/index.html.erb +43 -0
- data/app/views/good_pipeline/pipelines/show.html.erb +40 -0
- data/app/views/layouts/good_pipeline/application.html.erb +40 -0
- data/config/routes.rb +13 -0
- data/demo/Rakefile +5 -0
- data/demo/app/jobs/always_failing_job.rb +12 -0
- data/demo/app/jobs/application_job.rb +4 -0
- data/demo/app/jobs/cleanup_job.rb +5 -0
- data/demo/app/jobs/download_job.rb +5 -0
- data/demo/app/jobs/failing_job.rb +12 -0
- data/demo/app/jobs/publish_job.rb +5 -0
- data/demo/app/jobs/retryable_job.rb +19 -0
- data/demo/app/jobs/thumbnail_job.rb +5 -0
- data/demo/app/jobs/transcode_job.rb +5 -0
- data/demo/app/pipelines/analytics_pipeline.rb +7 -0
- data/demo/app/pipelines/archive_pipeline.rb +7 -0
- data/demo/app/pipelines/continue_test_pipeline.rb +11 -0
- data/demo/app/pipelines/halt_test_pipeline.rb +10 -0
- data/demo/app/pipelines/notification_pipeline.rb +7 -0
- data/demo/app/pipelines/test_pipeline.rb +5 -0
- data/demo/app/pipelines/video_processing_pipeline.rb +14 -0
- data/demo/bin/rails +6 -0
- data/demo/config/application.rb +18 -0
- data/demo/config/boot.rb +5 -0
- data/demo/config/database.yml +15 -0
- data/demo/config/environment.rb +5 -0
- data/demo/config/environments/development.rb +9 -0
- data/demo/config/environments/test.rb +10 -0
- data/demo/config/routes.rb +6 -0
- data/demo/config.ru +5 -0
- data/demo/db/migrate/20260319205325_create_good_jobs.rb +112 -0
- data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +53 -0
- data/demo/db/seeds.rb +153 -0
- data/demo/test/good_pipeline/test_chain_record.rb +29 -0
- data/demo/test/good_pipeline/test_cleanup.rb +93 -0
- data/demo/test/good_pipeline/test_coordinator.rb +286 -0
- data/demo/test/good_pipeline/test_dependency_record.rb +46 -0
- data/demo/test/good_pipeline/test_failure_metadata.rb +77 -0
- data/demo/test/good_pipeline/test_introspection.rb +46 -0
- data/demo/test/good_pipeline/test_pipeline_callback_job.rb +132 -0
- data/demo/test/good_pipeline/test_pipeline_reconciliation_job.rb +33 -0
- data/demo/test/good_pipeline/test_pipeline_record.rb +183 -0
- data/demo/test/good_pipeline/test_runner.rb +86 -0
- data/demo/test/good_pipeline/test_step_finished_job.rb +37 -0
- data/demo/test/good_pipeline/test_step_record.rb +208 -0
- data/demo/test/integration/test_concurrent_fan_in.rb +109 -0
- data/demo/test/integration/test_end_to_end.rb +89 -0
- data/demo/test/integration/test_enqueue_atomicity.rb +59 -0
- data/demo/test/integration/test_pipeline_chaining.rb +183 -0
- data/demo/test/integration/test_retry_scenarios.rb +90 -0
- data/demo/test/integration/test_step_finished_idempotency.rb +38 -0
- data/demo/test/test_helper.rb +71 -0
- data/dev-docker-compose.yml +16 -0
- data/docs/.vitepress/config.mts +66 -0
- data/docs/.vitepress/theme/custom.css +21 -0
- data/docs/.vitepress/theme/index.ts +4 -0
- data/docs/architecture.md +184 -0
- data/docs/callbacks.md +66 -0
- data/docs/cleanup.md +45 -0
- data/docs/dag-validation.md +88 -0
- data/docs/dashboard.md +66 -0
- data/docs/defining-pipelines.md +167 -0
- data/docs/failure-strategies.md +138 -0
- data/docs/getting-started.md +77 -0
- data/docs/index.md +23 -0
- data/docs/introduction.md +42 -0
- data/docs/monitoring.md +103 -0
- data/docs/package-lock.json +2510 -0
- data/docs/package.json +11 -0
- data/docs/pipeline-chaining.md +104 -0
- data/docs/public/screenshots/definitions.png +0 -0
- data/docs/public/screenshots/index.png +0 -0
- data/docs/public/screenshots/show.png +0 -0
- data/docs/screenshots/definitions.png +0 -0
- data/docs/screenshots/index.png +0 -0
- data/docs/screenshots/show.png +0 -0
- data/lib/generators/good_pipeline/install/install_generator.rb +20 -0
- data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +51 -0
- data/lib/good_pipeline/chain.rb +54 -0
- data/lib/good_pipeline/chain_coordinator.rb +53 -0
- data/lib/good_pipeline/coordinator.rb +176 -0
- data/lib/good_pipeline/cycle_detector.rb +36 -0
- data/lib/good_pipeline/engine.rb +23 -0
- data/lib/good_pipeline/errors.rb +11 -0
- data/lib/good_pipeline/failure_metadata.rb +29 -0
- data/lib/good_pipeline/graph_validator.rb +71 -0
- data/lib/good_pipeline/pipeline.rb +122 -0
- data/lib/good_pipeline/runner.rb +63 -0
- data/lib/good_pipeline/step_definition.rb +18 -0
- data/lib/good_pipeline/version.rb +5 -0
- data/lib/good_pipeline.rb +45 -0
- data/mise.toml +10 -0
- data/sig/good_pipeline.rbs +4 -0
- metadata +204 -0
data/docs/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Pipeline Chaining
|
|
2
|
+
|
|
3
|
+
GoodPipeline supports wiring pipelines together into pipeline-level DAGs. A downstream pipeline starts only after all its upstream pipelines succeed.
|
|
4
|
+
|
|
5
|
+
## Serial chain
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
VideoProcessingPipeline
|
|
9
|
+
.run(video_id: 123)
|
|
10
|
+
.then(NotificationPipeline, with: { video_id: 123 })
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`NotificationPipeline` starts only after `VideoProcessingPipeline` succeeds.
|
|
14
|
+
|
|
15
|
+
## Multi-step serial chain
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
VideoProcessingPipeline
|
|
19
|
+
.run(video_id: 123)
|
|
20
|
+
.then(QualityCheckPipeline, with: { video_id: 123 })
|
|
21
|
+
.then(NotificationPipeline, with: { video_id: 123 })
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Each pipeline waits for the previous one to succeed.
|
|
25
|
+
|
|
26
|
+
## Fan-out
|
|
27
|
+
|
|
28
|
+
Multiple downstream pipelines start in parallel when the upstream succeeds:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
VideoProcessingPipeline
|
|
32
|
+
.run(video_id: 123)
|
|
33
|
+
.then(
|
|
34
|
+
[NotificationPipeline, with: { video_id: 123 }],
|
|
35
|
+
[AnalyticsPipeline, with: { video_id: 123 }]
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Both `NotificationPipeline` and `AnalyticsPipeline` start simultaneously.
|
|
40
|
+
|
|
41
|
+
## Fan-out then fan-in
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
VideoProcessingPipeline
|
|
45
|
+
.run(video_id: 123)
|
|
46
|
+
.then(QualityCheckPipeline, with: { video_id: 123 })
|
|
47
|
+
.then(
|
|
48
|
+
[NotificationPipeline, with: { video_id: 123 }],
|
|
49
|
+
[AnalyticsPipeline, with: { video_id: 123 }]
|
|
50
|
+
)
|
|
51
|
+
.then(ArchivePipeline, with: { video_id: 123 })
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
VideoProcessingPipeline
|
|
56
|
+
↓
|
|
57
|
+
QualityCheckPipeline
|
|
58
|
+
↓
|
|
59
|
+
┌────┴────┐
|
|
60
|
+
↓ ↓
|
|
61
|
+
Notification Analytics
|
|
62
|
+
└────┬────┘
|
|
63
|
+
↓
|
|
64
|
+
ArchivePipeline
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`ArchivePipeline` waits for **both** `NotificationPipeline` and `AnalyticsPipeline` to succeed.
|
|
68
|
+
|
|
69
|
+
## Parallel start
|
|
70
|
+
|
|
71
|
+
Run multiple pipelines in parallel from the start using `GoodPipeline.run`:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
GoodPipeline.run(
|
|
75
|
+
[VideoProcessingPipeline, with: { video_id: 123 }],
|
|
76
|
+
[AudioProcessingPipeline, with: { audio_id: 456 }]
|
|
77
|
+
).then(MergeMediaPipeline, with: { video_id: 123, audio_id: 456 })
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Both pipelines start immediately. `MergeMediaPipeline` waits for both to succeed.
|
|
81
|
+
|
|
82
|
+
## How `.then` works internally
|
|
83
|
+
|
|
84
|
+
`.then` returns a `GoodPipeline::Chain` object which:
|
|
85
|
+
|
|
86
|
+
1. Creates downstream pipeline records with `status: pending` — params are stored immediately at chain registration time
|
|
87
|
+
2. Creates `good_pipeline_chains` rows linking upstream to downstream pipeline IDs
|
|
88
|
+
3. After any upstream pipeline reaches a terminal state, the chain coordinator checks if all upstreams for each downstream have succeeded
|
|
89
|
+
4. If all upstreams succeeded, the downstream pipeline starts (root steps are enqueued)
|
|
90
|
+
5. If any upstream fails, halts, or is skipped, the downstream pipeline is set to `skipped`
|
|
91
|
+
|
|
92
|
+
The chain coordinator uses the **same atomic row-locking pattern** (`FOR UPDATE SKIP LOCKED`) as the step-level coordinator to prevent double-start races.
|
|
93
|
+
|
|
94
|
+
## Failure propagation
|
|
95
|
+
|
|
96
|
+
If any upstream pipeline in a chain fails, halts, or is skipped:
|
|
97
|
+
|
|
98
|
+
- The downstream pipeline transitions to `skipped`
|
|
99
|
+
- Any further downstream pipelines are also recursively `skipped`
|
|
100
|
+
- `on_complete` callbacks fire on skipped pipelines, but `on_failure` does **not** — being skipped is not considered a failure
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
A (failed) → B (skipped) → C (skipped) → D (skipped)
|
|
104
|
+
```
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module GoodPipeline
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
include ActiveRecord::Generators::Migration
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
desc "Creates the GoodPipeline migration file."
|
|
13
|
+
def create_migration_file
|
|
14
|
+
migration_template(
|
|
15
|
+
"create_good_pipeline_tables.rb.erb",
|
|
16
|
+
"db/migrate/create_good_pipeline_tables.rb"
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
class CreateGoodPipelineTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
# Uncomment for Postgres v12 or earlier to enable gen_random_uuid() support
|
|
4
|
+
# enable_extension 'pgcrypto'
|
|
5
|
+
|
|
6
|
+
create_table :good_pipeline_pipelines, id: :uuid do |t|
|
|
7
|
+
t.string :type, null: false
|
|
8
|
+
t.jsonb :params, null: false, default: {}
|
|
9
|
+
t.string :status, null: false, default: "pending"
|
|
10
|
+
t.boolean :halt_triggered, null: false, default: false
|
|
11
|
+
t.uuid :good_job_batch_id
|
|
12
|
+
t.string :on_failure_strategy, null: false, default: "halt"
|
|
13
|
+
t.datetime :callbacks_dispatched_at
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :good_pipeline_pipelines, :status
|
|
19
|
+
|
|
20
|
+
create_table :good_pipeline_steps, id: :uuid do |t|
|
|
21
|
+
t.references :pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
|
|
22
|
+
t.string :key, null: false
|
|
23
|
+
t.string :job_class, null: false
|
|
24
|
+
t.jsonb :params, null: false, default: {}
|
|
25
|
+
t.string :coordination_status, null: false, default: "pending"
|
|
26
|
+
t.string :on_failure_strategy
|
|
27
|
+
t.string :queue
|
|
28
|
+
t.integer :priority
|
|
29
|
+
t.uuid :good_job_batch_id
|
|
30
|
+
t.uuid :good_job_id
|
|
31
|
+
t.integer :attempts
|
|
32
|
+
t.string :error_class
|
|
33
|
+
t.text :error_message
|
|
34
|
+
|
|
35
|
+
t.timestamps
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
add_index :good_pipeline_steps, %i[pipeline_id key], unique: true
|
|
39
|
+
|
|
40
|
+
create_table :good_pipeline_dependencies do |t|
|
|
41
|
+
t.references :pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
|
|
42
|
+
t.references :step, null: false, foreign_key: { to_table: :good_pipeline_steps }, type: :uuid
|
|
43
|
+
t.references :depends_on_step, null: false, foreign_key: { to_table: :good_pipeline_steps }, type: :uuid
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
create_table :good_pipeline_chains, id: :uuid do |t|
|
|
47
|
+
t.references :upstream_pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
|
|
48
|
+
t.references :downstream_pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module GoodPipeline
|
|
6
|
+
class Chain
|
|
7
|
+
extend Forwardable
|
|
8
|
+
|
|
9
|
+
attr_reader :pipeline_records
|
|
10
|
+
|
|
11
|
+
def_delegators :first_pipeline_record,
|
|
12
|
+
:id, :status, :params, :type, :terminal?, :reload,
|
|
13
|
+
:steps, :dependencies, :halt_triggered?,
|
|
14
|
+
:callbacks_dispatched_at, :on_failure_strategy
|
|
15
|
+
|
|
16
|
+
def initialize(pipeline_records)
|
|
17
|
+
@pipeline_records = Array(pipeline_records)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def then(*arguments) # rubocop:disable Metrics/MethodLength
|
|
21
|
+
configs = normalize_arguments(arguments)
|
|
22
|
+
downstream_records = []
|
|
23
|
+
|
|
24
|
+
configs.each do |pipeline_class, pipeline_params|
|
|
25
|
+
instance = pipeline_class.build(**pipeline_params)
|
|
26
|
+
downstream_record = Runner.call(instance, start: false)
|
|
27
|
+
downstream_records << downstream_record
|
|
28
|
+
|
|
29
|
+
@pipeline_records.each do |upstream_record|
|
|
30
|
+
ChainRecord.create!(
|
|
31
|
+
upstream_pipeline: upstream_record,
|
|
32
|
+
downstream_pipeline: downstream_record
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Chain.new(downstream_records)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def first_pipeline_record
|
|
43
|
+
@pipeline_records.first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def normalize_arguments(arguments)
|
|
47
|
+
if arguments.first.is_a?(Array)
|
|
48
|
+
arguments.map { |config| GoodPipeline.extract_pipeline_config(config) }
|
|
49
|
+
else
|
|
50
|
+
[GoodPipeline.extract_pipeline_config(arguments)]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodPipeline
|
|
4
|
+
class ChainCoordinator
|
|
5
|
+
def self.propagate_terminal_state(pipeline)
|
|
6
|
+
pipeline.downstream_pipelines.each do |downstream_pipeline|
|
|
7
|
+
try_start_downstream(downstream_pipeline.id)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.try_start_downstream(pipeline_id) # rubocop:disable Metrics/MethodLength
|
|
12
|
+
skipped_downstream_ids = nil
|
|
13
|
+
|
|
14
|
+
PipelineRecord.transaction do
|
|
15
|
+
locked = PipelineRecord.lock("FOR UPDATE SKIP LOCKED").find_by(id: pipeline_id)
|
|
16
|
+
return unless locked&.pending?
|
|
17
|
+
|
|
18
|
+
if should_skip_downstream?(locked)
|
|
19
|
+
locked.transition_to!(:skipped)
|
|
20
|
+
Coordinator.dispatch_callbacks_once(locked, :skipped)
|
|
21
|
+
skipped_downstream_ids = locked.downstream_pipelines.pluck(:id)
|
|
22
|
+
elsif all_upstreams_succeeded?(locked)
|
|
23
|
+
start_pipeline(locked)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
skipped_downstream_ids&.each { |downstream_id| try_start_downstream(downstream_id) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.should_skip_downstream?(pipeline)
|
|
31
|
+
pipeline.upstream_pipelines.any? do |upstream|
|
|
32
|
+
upstream.failed? || upstream.halted? || upstream.skipped?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.all_upstreams_succeeded?(pipeline)
|
|
37
|
+
pipeline.upstream_pipelines.all?(&:succeeded?)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.start_pipeline(pipeline_record)
|
|
41
|
+
pipeline_record.transition_to!(:running)
|
|
42
|
+
|
|
43
|
+
root_step_ids = pipeline_record.steps.where.missing(:upstream_dependencies).pluck(:id)
|
|
44
|
+
|
|
45
|
+
root_step_ids.each do |step_id|
|
|
46
|
+
Coordinator.try_enqueue_step(step_id)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private_class_method :try_start_downstream, :all_upstreams_succeeded?,
|
|
51
|
+
:should_skip_downstream?, :start_pipeline
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodPipeline
|
|
4
|
+
class Coordinator # rubocop:disable Metrics/ClassLength
|
|
5
|
+
def self.complete_step(step, succeeded:)
|
|
6
|
+
return if step.terminal_coordination_status?
|
|
7
|
+
|
|
8
|
+
record_step_outcome(step, succeeded)
|
|
9
|
+
propagate_halt(step) if !succeeded && step.pipeline.halt?
|
|
10
|
+
unblock_downstream_steps(step)
|
|
11
|
+
recompute_pipeline_status(step.pipeline.reload)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.record_step_outcome(step, succeeded)
|
|
15
|
+
StepRecord.transaction do
|
|
16
|
+
if succeeded
|
|
17
|
+
step.transition_coordination_status_to!(:succeeded)
|
|
18
|
+
else
|
|
19
|
+
record_step_failure(step)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.record_step_failure(step)
|
|
25
|
+
metadata = FailureMetadata.extract(step)
|
|
26
|
+
step.transition_coordination_status_to!(:failed)
|
|
27
|
+
step.update_columns(
|
|
28
|
+
error_class: metadata.error_class,
|
|
29
|
+
error_message: metadata.error_message,
|
|
30
|
+
attempts: metadata.attempts
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.propagate_halt(step)
|
|
35
|
+
pipeline = step.pipeline
|
|
36
|
+
StepRecord.transaction do
|
|
37
|
+
pipeline.update_column(:halt_triggered, true)
|
|
38
|
+
skip_all_pending_steps(pipeline, except_dependents_of: step)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.unblock_downstream_steps(step)
|
|
43
|
+
step.downstream_steps.each do |downstream_step|
|
|
44
|
+
try_enqueue_step(downstream_step.id)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.try_enqueue_step(step_id)
|
|
49
|
+
skipped_downstream_ids = nil
|
|
50
|
+
|
|
51
|
+
StepRecord.transaction do
|
|
52
|
+
locked_step = StepRecord.lock("FOR UPDATE SKIP LOCKED").find_by(id: step_id)
|
|
53
|
+
return unless locked_step&.pending?
|
|
54
|
+
return if locked_step.good_job_id.present?
|
|
55
|
+
|
|
56
|
+
skipped_downstream_ids = resolve_step(locked_step)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
skipped_downstream_ids&.each { |downstream_step_id| try_enqueue_step(downstream_step_id) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.resolve_step(locked_step)
|
|
63
|
+
if should_skip?(locked_step)
|
|
64
|
+
locked_step.transition_coordination_status_to!(:skipped)
|
|
65
|
+
locked_step.downstream_steps.pluck(:id)
|
|
66
|
+
else
|
|
67
|
+
enqueue_user_job(locked_step) if all_upstreams_satisfied?(locked_step)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.enqueue_user_job(step)
|
|
73
|
+
step.transition_coordination_status_to!(:enqueued)
|
|
74
|
+
|
|
75
|
+
batch = build_step_batch(step)
|
|
76
|
+
batch.enqueue { enqueue_step_job(step) }
|
|
77
|
+
step.update_column(:good_job_batch_id, batch.id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.build_step_batch(step)
|
|
81
|
+
batch = GoodJob::Batch.new
|
|
82
|
+
batch.on_finish = "GoodPipeline::StepFinishedJob"
|
|
83
|
+
batch.properties = { step_id: step.id }
|
|
84
|
+
batch
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.enqueue_step_job(step)
|
|
88
|
+
job = build_job_instance(step)
|
|
89
|
+
enqueued_job = job.enqueue
|
|
90
|
+
step.update_column(:good_job_id, enqueued_job.provider_job_id || enqueued_job.job_id)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.build_job_instance(step)
|
|
94
|
+
job = step.job_class.constantize.new(**step.params.symbolize_keys)
|
|
95
|
+
job.queue_name = step.queue if step.queue.present?
|
|
96
|
+
job.priority = step.priority if step.priority.present?
|
|
97
|
+
job
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.recompute_pipeline_status(pipeline)
|
|
101
|
+
steps = pipeline.steps.reload
|
|
102
|
+
|
|
103
|
+
return if steps.any? { |step| step.pending? || step.enqueued? }
|
|
104
|
+
return if pipeline.terminal?
|
|
105
|
+
|
|
106
|
+
new_status = derive_terminal_status(steps, pipeline)
|
|
107
|
+
pipeline.transition_to!(new_status)
|
|
108
|
+
dispatch_callbacks_once(pipeline, new_status)
|
|
109
|
+
ChainCoordinator.propagate_terminal_state(pipeline)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.derive_terminal_status(steps, pipeline)
|
|
113
|
+
has_failures = steps.any?(&:failed?)
|
|
114
|
+
|
|
115
|
+
return :succeeded unless has_failures
|
|
116
|
+
return :halted if pipeline.halt_triggered?
|
|
117
|
+
|
|
118
|
+
:failed
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.dispatch_callbacks_once(pipeline, new_status)
|
|
122
|
+
PipelineRecord.transaction do
|
|
123
|
+
locked = PipelineRecord.lock("FOR UPDATE").find(pipeline.id)
|
|
124
|
+
return if locked.callbacks_dispatched_at.present?
|
|
125
|
+
|
|
126
|
+
locked.update!(callbacks_dispatched_at: Time.current)
|
|
127
|
+
PipelineCallbackJob.perform_later(locked.id, new_status.to_s)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- Private helpers ---
|
|
132
|
+
|
|
133
|
+
def self.all_upstreams_satisfied?(step)
|
|
134
|
+
step.upstream_steps.all? do |upstream|
|
|
135
|
+
upstream.succeeded? ||
|
|
136
|
+
(upstream.failed? && effective_strategy(upstream) == :ignore)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.should_skip?(step)
|
|
141
|
+
step.pending? &&
|
|
142
|
+
step.upstream_steps.any? { |upstream| permanently_unsatisfied?(upstream) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.permanently_unsatisfied?(upstream)
|
|
146
|
+
upstream.terminal_coordination_status? &&
|
|
147
|
+
!upstream.succeeded? &&
|
|
148
|
+
effective_strategy(upstream) != :ignore
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.skip_all_pending_steps(pipeline, except_dependents_of:)
|
|
152
|
+
exempt_step_ids = if effective_strategy(except_dependents_of) == :ignore
|
|
153
|
+
except_dependents_of.downstream_steps.pluck(:id)
|
|
154
|
+
else
|
|
155
|
+
[]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
pipeline.steps.pending.find_each do |pending_step|
|
|
159
|
+
next if exempt_step_ids.include?(pending_step.id)
|
|
160
|
+
|
|
161
|
+
pending_step.transition_coordination_status_to!(:skipped)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.effective_strategy(step)
|
|
166
|
+
step.on_failure_strategy&.to_sym || step.pipeline.on_failure_strategy.to_sym
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private_class_method :record_step_outcome, :record_step_failure,
|
|
170
|
+
:propagate_halt, :unblock_downstream_steps,
|
|
171
|
+
:all_upstreams_satisfied?, :should_skip?, :permanently_unsatisfied?,
|
|
172
|
+
:skip_all_pending_steps, :effective_strategy,
|
|
173
|
+
:enqueue_user_job, :build_step_batch, :enqueue_step_job,
|
|
174
|
+
:build_job_instance, :derive_terminal_status, :resolve_step
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodPipeline
|
|
4
|
+
class CycleDetector
|
|
5
|
+
def self.check!(steps, edges)
|
|
6
|
+
color = Hash.new(:white)
|
|
7
|
+
path = []
|
|
8
|
+
|
|
9
|
+
steps.each_key do |key|
|
|
10
|
+
next if color[key] == :black
|
|
11
|
+
|
|
12
|
+
dfs(key, edges, color, path)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.dfs(node, edges, color, path)
|
|
17
|
+
color[node] = :grey
|
|
18
|
+
path.push(node)
|
|
19
|
+
|
|
20
|
+
(edges[node] || []).each do |neighbor|
|
|
21
|
+
raise_cycle!(path, neighbor) if color[neighbor] == :grey
|
|
22
|
+
dfs(neighbor, edges, color, path) if color[neighbor] == :white
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
path.pop
|
|
26
|
+
color[node] = :black
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.raise_cycle!(path, neighbor)
|
|
30
|
+
cycle = path.drop_while { |node| node != neighbor } + [neighbor]
|
|
31
|
+
raise InvalidPipelineError, "cycle detected: #{cycle.map { |key| ":#{key}" }.join(" -> ")}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private_class_method :dfs, :raise_cycle!
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodPipeline
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
engine_name "good_pipeline"
|
|
6
|
+
isolate_namespace GoodPipeline
|
|
7
|
+
|
|
8
|
+
initializer "good_pipeline.check_good_job_config" do
|
|
9
|
+
ActiveSupport.on_load(:active_job) do
|
|
10
|
+
next if GoodJob.preserve_job_records == true
|
|
11
|
+
|
|
12
|
+
raise GoodPipeline::ConfigurationError, "GoodPipeline requires GoodJob.preserve_job_records = true"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "good_pipeline.cleanup_hook" do
|
|
17
|
+
ActiveSupport::Notifications.subscribe("cleanup_preserved_jobs.good_job") do |event|
|
|
18
|
+
timestamp = event.payload[:timestamp]
|
|
19
|
+
GoodPipeline.cleanup_preserved_pipelines(older_than: timestamp) if timestamp
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodPipeline
|
|
4
|
+
class FailureMetadata
|
|
5
|
+
Result = Struct.new(:error_class, :error_message, :attempts)
|
|
6
|
+
|
|
7
|
+
def self.extract(step)
|
|
8
|
+
good_job = GoodJob::Job.find_by(id: step.good_job_id)
|
|
9
|
+
return Result.new(error_class: nil, error_message: nil, attempts: 0) unless good_job
|
|
10
|
+
|
|
11
|
+
error_class, error_message = parse_error(good_job.error)
|
|
12
|
+
|
|
13
|
+
Result.new(
|
|
14
|
+
error_class: error_class,
|
|
15
|
+
error_message: error_message,
|
|
16
|
+
attempts: good_job.executions_count
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.parse_error(error_string)
|
|
21
|
+
return [nil, nil] if error_string.blank?
|
|
22
|
+
|
|
23
|
+
parts = error_string.split(GoodJob::Job::ERROR_MESSAGE_SEPARATOR, 2)
|
|
24
|
+
[parts[0]&.strip, parts[1]&.strip]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private_class_method :parse_error
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodPipeline
|
|
4
|
+
class GraphValidator
|
|
5
|
+
def self.validate!(step_definitions)
|
|
6
|
+
new(step_definitions).validate!
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(step_definitions)
|
|
10
|
+
@step_definitions = step_definitions
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def validate!
|
|
14
|
+
check_empty_pipeline!
|
|
15
|
+
check_duplicate_keys!
|
|
16
|
+
build_steps_by_key!
|
|
17
|
+
check_self_dependencies!
|
|
18
|
+
check_unknown_references!
|
|
19
|
+
check_cycles!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def check_empty_pipeline!
|
|
25
|
+
raise InvalidPipelineError, "pipeline has no steps" if @step_definitions.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check_duplicate_keys!
|
|
29
|
+
seen = {}
|
|
30
|
+
@step_definitions.each do |step|
|
|
31
|
+
raise InvalidPipelineError, "duplicate step key :#{step.key}" if seen.key?(step.key)
|
|
32
|
+
|
|
33
|
+
seen[step.key] = true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_steps_by_key!
|
|
38
|
+
@steps_by_key = @step_definitions.to_h { |step| [step.key, step] }
|
|
39
|
+
end
|
|
40
|
+
|
|
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)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def check_unknown_references!
|
|
48
|
+
@steps_by_key.each_value do |step|
|
|
49
|
+
step.dependencies.each do |dependency_key|
|
|
50
|
+
unless @steps_by_key.key?(dependency_key)
|
|
51
|
+
raise InvalidPipelineError, "step :#{step.key} references unknown dependency :#{dependency_key}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
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
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|