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.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +217 -0
  6. data/Rakefile +20 -0
  7. data/app/controllers/good_pipeline/application_controller.rb +9 -0
  8. data/app/controllers/good_pipeline/frontends_controller.rb +31 -0
  9. data/app/controllers/good_pipeline/pipelines_controller.rb +57 -0
  10. data/app/frontend/good_pipeline/style.css +518 -0
  11. data/app/helpers/good_pipeline/pipelines_helper.rb +119 -0
  12. data/app/jobs/good_pipeline/pipeline_callback_job.rb +52 -0
  13. data/app/jobs/good_pipeline/pipeline_reconciliation_job.rb +10 -0
  14. data/app/jobs/good_pipeline/step_finished_job.rb +10 -0
  15. data/app/models/good_pipeline/chain_record.rb +18 -0
  16. data/app/models/good_pipeline/dependency_record.rb +23 -0
  17. data/app/models/good_pipeline/pipeline_record.rb +73 -0
  18. data/app/models/good_pipeline/step_record.rb +74 -0
  19. data/app/views/good_pipeline/pipelines/_chain_links.html.erb +30 -0
  20. data/app/views/good_pipeline/pipelines/_pagination.html.erb +24 -0
  21. data/app/views/good_pipeline/pipelines/_pipeline_row.html.erb +7 -0
  22. data/app/views/good_pipeline/pipelines/_steps_table.html.erb +33 -0
  23. data/app/views/good_pipeline/pipelines/definitions.html.erb +49 -0
  24. data/app/views/good_pipeline/pipelines/index.html.erb +43 -0
  25. data/app/views/good_pipeline/pipelines/show.html.erb +40 -0
  26. data/app/views/layouts/good_pipeline/application.html.erb +40 -0
  27. data/config/routes.rb +13 -0
  28. data/demo/Rakefile +5 -0
  29. data/demo/app/jobs/always_failing_job.rb +12 -0
  30. data/demo/app/jobs/application_job.rb +4 -0
  31. data/demo/app/jobs/cleanup_job.rb +5 -0
  32. data/demo/app/jobs/download_job.rb +5 -0
  33. data/demo/app/jobs/failing_job.rb +12 -0
  34. data/demo/app/jobs/publish_job.rb +5 -0
  35. data/demo/app/jobs/retryable_job.rb +19 -0
  36. data/demo/app/jobs/thumbnail_job.rb +5 -0
  37. data/demo/app/jobs/transcode_job.rb +5 -0
  38. data/demo/app/pipelines/analytics_pipeline.rb +7 -0
  39. data/demo/app/pipelines/archive_pipeline.rb +7 -0
  40. data/demo/app/pipelines/continue_test_pipeline.rb +11 -0
  41. data/demo/app/pipelines/halt_test_pipeline.rb +10 -0
  42. data/demo/app/pipelines/notification_pipeline.rb +7 -0
  43. data/demo/app/pipelines/test_pipeline.rb +5 -0
  44. data/demo/app/pipelines/video_processing_pipeline.rb +14 -0
  45. data/demo/bin/rails +6 -0
  46. data/demo/config/application.rb +18 -0
  47. data/demo/config/boot.rb +5 -0
  48. data/demo/config/database.yml +15 -0
  49. data/demo/config/environment.rb +5 -0
  50. data/demo/config/environments/development.rb +9 -0
  51. data/demo/config/environments/test.rb +10 -0
  52. data/demo/config/routes.rb +6 -0
  53. data/demo/config.ru +5 -0
  54. data/demo/db/migrate/20260319205325_create_good_jobs.rb +112 -0
  55. data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +53 -0
  56. data/demo/db/seeds.rb +153 -0
  57. data/demo/test/good_pipeline/test_chain_record.rb +29 -0
  58. data/demo/test/good_pipeline/test_cleanup.rb +93 -0
  59. data/demo/test/good_pipeline/test_coordinator.rb +286 -0
  60. data/demo/test/good_pipeline/test_dependency_record.rb +46 -0
  61. data/demo/test/good_pipeline/test_failure_metadata.rb +77 -0
  62. data/demo/test/good_pipeline/test_introspection.rb +46 -0
  63. data/demo/test/good_pipeline/test_pipeline_callback_job.rb +132 -0
  64. data/demo/test/good_pipeline/test_pipeline_reconciliation_job.rb +33 -0
  65. data/demo/test/good_pipeline/test_pipeline_record.rb +183 -0
  66. data/demo/test/good_pipeline/test_runner.rb +86 -0
  67. data/demo/test/good_pipeline/test_step_finished_job.rb +37 -0
  68. data/demo/test/good_pipeline/test_step_record.rb +208 -0
  69. data/demo/test/integration/test_concurrent_fan_in.rb +109 -0
  70. data/demo/test/integration/test_end_to_end.rb +89 -0
  71. data/demo/test/integration/test_enqueue_atomicity.rb +59 -0
  72. data/demo/test/integration/test_pipeline_chaining.rb +183 -0
  73. data/demo/test/integration/test_retry_scenarios.rb +90 -0
  74. data/demo/test/integration/test_step_finished_idempotency.rb +38 -0
  75. data/demo/test/test_helper.rb +71 -0
  76. data/dev-docker-compose.yml +16 -0
  77. data/docs/.vitepress/config.mts +66 -0
  78. data/docs/.vitepress/theme/custom.css +21 -0
  79. data/docs/.vitepress/theme/index.ts +4 -0
  80. data/docs/architecture.md +184 -0
  81. data/docs/callbacks.md +66 -0
  82. data/docs/cleanup.md +45 -0
  83. data/docs/dag-validation.md +88 -0
  84. data/docs/dashboard.md +66 -0
  85. data/docs/defining-pipelines.md +167 -0
  86. data/docs/failure-strategies.md +138 -0
  87. data/docs/getting-started.md +77 -0
  88. data/docs/index.md +23 -0
  89. data/docs/introduction.md +42 -0
  90. data/docs/monitoring.md +103 -0
  91. data/docs/package-lock.json +2510 -0
  92. data/docs/package.json +11 -0
  93. data/docs/pipeline-chaining.md +104 -0
  94. data/docs/public/screenshots/definitions.png +0 -0
  95. data/docs/public/screenshots/index.png +0 -0
  96. data/docs/public/screenshots/show.png +0 -0
  97. data/docs/screenshots/definitions.png +0 -0
  98. data/docs/screenshots/index.png +0 -0
  99. data/docs/screenshots/show.png +0 -0
  100. data/lib/generators/good_pipeline/install/install_generator.rb +20 -0
  101. data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +51 -0
  102. data/lib/good_pipeline/chain.rb +54 -0
  103. data/lib/good_pipeline/chain_coordinator.rb +53 -0
  104. data/lib/good_pipeline/coordinator.rb +176 -0
  105. data/lib/good_pipeline/cycle_detector.rb +36 -0
  106. data/lib/good_pipeline/engine.rb +23 -0
  107. data/lib/good_pipeline/errors.rb +11 -0
  108. data/lib/good_pipeline/failure_metadata.rb +29 -0
  109. data/lib/good_pipeline/graph_validator.rb +71 -0
  110. data/lib/good_pipeline/pipeline.rb +122 -0
  111. data/lib/good_pipeline/runner.rb +63 -0
  112. data/lib/good_pipeline/step_definition.rb +18 -0
  113. data/lib/good_pipeline/version.rb +5 -0
  114. data/lib/good_pipeline.rb +45 -0
  115. data/mise.toml +10 -0
  116. data/sig/good_pipeline.rbs +4 -0
  117. metadata +204 -0
data/docs/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "private": true,
3
+ "scripts": {
4
+ "docs:dev": "vitepress dev",
5
+ "docs:build": "vitepress build",
6
+ "docs:preview": "vitepress preview"
7
+ },
8
+ "devDependencies": {
9
+ "vitepress": "^1.6.3"
10
+ }
11
+ }
@@ -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
@@ -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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ class Error < StandardError; end
5
+
6
+ class InvalidPipelineError < Error; end
7
+
8
+ class InvalidTransition < Error; end
9
+
10
+ class ConfigurationError < Error; end
11
+ 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