good_pipeline 0.3.0 → 0.4.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: 350a7e051f704db8ee906a90bb8f641f1373be815aceb9adccc8cd17b2d38640
4
- data.tar.gz: f0cebfb8f77d35e043df87e71e5698b4e6ec08a468d95702a5a3a489eb1debf9
3
+ metadata.gz: 38ec7eb9fb3cec2b9109b9695b0084aff2fa111440cd8fb76dbe53be59bd8e06
4
+ data.tar.gz: 71094436abc0d2c393b3d188510608bf714867201f2fc13eac4cc218d6b8a420
5
5
  SHA512:
6
- metadata.gz: 682725126bc0643cd8ec88d249ef7e93b760d646a4aac30cbb693c1bde6d1c0b3d30494623eb5ffed8cc2845f62dc3642501648bf65cbf3adc43577f13734270
7
- data.tar.gz: 5e2aae2df9e5b8997b85a4aff15d0989fa24f27b6e607332d82421c5e5b33d0a4171e455ddc04eab71f790657fe9f71d59b412b7ffa054b7f15c60148b1a1536
6
+ metadata.gz: c3520cff350de86f2d2820d7a4406f2b81407079c1d00ee8ecfc4dff02e23f54dcedb1ae61d3a32594a4680b111b4b127fe2808ecf08c4705c4e0732e04643b9
7
+ data.tar.gz: 2a91e6a2b050392b6e5e8c1af31bd93a8e05fe5d23f1f940d0bbe97df59c381b7286bf10a8f3590789b342d079d66b9e1c1433e00a03eaa42d31c277e8e6218f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-04-02
4
+
5
+ ### Performance
6
+
7
+ - **Bulk root step enqueuing** — pipelines with multiple root steps now enqueue all of them via `GoodJob::Batch.enqueue_all` in a fixed number of queries instead of ~9 queries per step. Both `Runner#enqueue_root_steps` and `ChainCoordinator#start_pipeline` use the new `Coordinator.bulk_enqueue_steps` method.
8
+
9
+ ### Added
10
+
11
+ - **Configurable queue names for internal jobs** — new `coordination_queue_name` and `callback_queue_name` settings control which queues `StepFinishedJob`, `PipelineReconciliationJob`, and `PipelineCallbackJob` run on. Configurable globally (`GoodPipeline.coordination_queue_name = "x"`) and per-pipeline via the class DSL. Defaults to `"good_pipeline_coordination"` and `"good_pipeline_callbacks"`.
12
+ - **`Coordinator.bulk_enqueue_steps`** — public method that loads pending steps, partitions branch steps for individual handling, and bulk-enqueues the rest via `Batch.enqueue_all`. Invalid job classes are failed individually without blocking valid steps.
13
+
14
+ ### Changed
15
+
16
+ - **Minimum GoodJob version** — bumped from `>= 3.10` to `>= 4.14` (required for `Batch.enqueue_all`).
17
+ - **`run_pipeline_to_completion` test helper** — extracted from 3 integration test files into `test_helper.rb`.
18
+
19
+ ## [0.3.1] - 2026-03-26
20
+
21
+ ### Added
22
+
23
+ - **`halt_pipeline!`** — call from any job to stop the pipeline early with a `succeeded` status. The halting step is marked `halted`, remaining pending steps are `skipped`, and the `on_success` callback fires. The GoodJob record completes as succeeded (no error, no discard). Available in all jobs via `GoodPipeline::Haltable`, included automatically by the Engine.
24
+ - **`halted` coordination status** — new terminal step status for steps that called `halt_pipeline!`. Treated as satisfied for downstream dependency resolution.
25
+ - **`halt_requested` column** — boolean column on steps table, set by `halt_pipeline!` and checked by the coordinator on step completion.
26
+ - **`good_job_id` index** — partial unique index on `good_job_id` for fast step lookup from within jobs.
27
+
3
28
  ## [0.3.0] - 2026-03-25
4
29
 
5
30
  ### Performance
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  DAG-based job pipeline orchestration for Rails, built on [GoodJob](https://github.com/bensheldon/good_job).
4
4
 
5
- Define multi-step workflows as directed acyclic graphs, where each step is a GoodJob job. GoodPipeline handles dependency resolution, parallel execution, failure strategies, pipeline chaining, and lifecycle callbacks. It also ships with a web dashboard.
5
+ Define multi-step workflows as directed acyclic graphs not linear chains. Steps run in parallel when they can and wait for dependencies when they must. GoodPipeline handles dependency resolution, parallel execution, failure strategies, conditional branching, pipeline chaining, and lifecycle callbacks. It also ships with a web dashboard.
6
6
 
7
7
  ## Requirements
8
8
 
@@ -15,6 +15,7 @@ module GoodPipeline
15
15
  " classDef failed fill:#f44336,color:#fff",
16
16
  " classDef skipped fill:#bdbdbd,color:#333",
17
17
  " classDef skipped_by_branch fill:#bdbdbd,color:#333",
18
+ " classDef halted fill:#8bc34a,color:#fff",
18
19
  " classDef branch fill:#ff9800,color:#fff,stroke:#f57c00",
19
20
  " classDef terminal fill:#1a1a2e,color:#fff,stroke:#1a1a2e"
20
21
  ].freeze
@@ -7,11 +7,11 @@ module GoodPipeline
7
7
  class StepRecord < ActiveRecord::Base
8
8
  self.table_name = "good_pipeline_steps"
9
9
 
10
- TERMINAL_COORDINATION_STATUSES = %w[succeeded failed skipped skipped_by_branch].freeze
10
+ TERMINAL_COORDINATION_STATUSES = %w[succeeded failed skipped skipped_by_branch halted].freeze
11
11
 
12
12
  VALID_COORDINATION_TRANSITIONS = {
13
- "pending" => %w[enqueued skipped skipped_by_branch succeeded failed],
14
- "enqueued" => %w[succeeded failed]
13
+ "pending" => %w[enqueued skipped skipped_by_branch succeeded failed halted],
14
+ "enqueued" => %w[succeeded failed halted]
15
15
  }.freeze
16
16
 
17
17
  enum :coordination_status, {
@@ -20,7 +20,8 @@ module GoodPipeline
20
20
  succeeded: "succeeded",
21
21
  failed: "failed",
22
22
  skipped: "skipped",
23
- skipped_by_branch: "skipped_by_branch"
23
+ skipped_by_branch: "skipped_by_branch",
24
+ halted: "halted"
24
25
  }
25
26
 
26
27
  enum :on_failure_strategy, { halt: "halt", continue: "continue", ignore: "ignore" }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HaltExecutionJob < ApplicationJob
4
+ def perform(**)
5
+ halt_pipeline!
6
+ end
7
+ end
@@ -31,6 +31,7 @@ class CreateGoodPipelineTables < ActiveRecord::Migration[8.1]
31
31
  t.uuid :good_job_batch_id
32
32
  t.uuid :good_job_id
33
33
  t.integer :pending_upstream_count, null: false, default: 0
34
+ t.boolean :halt_requested, null: false, default: false
34
35
  t.integer :attempts
35
36
  t.string :error_class
36
37
  t.text :error_message
@@ -40,6 +41,7 @@ class CreateGoodPipelineTables < ActiveRecord::Migration[8.1]
40
41
 
41
42
  add_index :good_pipeline_steps, %i[pipeline_id key], unique: true
42
43
  add_index :good_pipeline_steps, :coordination_status
44
+ add_index :good_pipeline_steps, :good_job_id, unique: true, where: "good_job_id IS NOT NULL"
43
45
 
44
46
  create_table :good_pipeline_dependencies do |t|
45
47
  t.references :pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestBulkEnqueue < ActiveSupport::TestCase
6
+ # --- basic enqueuing ---
7
+
8
+ def test_enqueues_multiple_steps
9
+ pipeline = create_pipeline(on_failure_strategy: "halt")
10
+ pipeline.update_columns(status: "running")
11
+ step_a = build_step(pipeline, key: "step_a", job_class: "DownloadJob")
12
+ step_b = build_step(pipeline, key: "step_b", job_class: "TranscodeJob")
13
+
14
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step_a.id, step_b.id])
15
+
16
+ step_a.reload
17
+ step_b.reload
18
+
19
+ assert_equal "enqueued", step_a.coordination_status
20
+ assert_equal "enqueued", step_b.coordination_status
21
+ refute_nil step_a.good_job_batch_id
22
+ refute_nil step_b.good_job_batch_id
23
+ refute_nil step_a.good_job_id
24
+ refute_nil step_b.good_job_id
25
+ end
26
+
27
+ def test_each_step_gets_its_own_batch
28
+ pipeline = create_pipeline(on_failure_strategy: "halt")
29
+ pipeline.update_columns(status: "running")
30
+ step_a = build_step(pipeline, key: "step_a", job_class: "DownloadJob")
31
+ step_b = build_step(pipeline, key: "step_b", job_class: "TranscodeJob")
32
+
33
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step_a.id, step_b.id])
34
+
35
+ refute_equal step_a.reload.good_job_batch_id, step_b.reload.good_job_batch_id
36
+ end
37
+
38
+ def test_good_job_id_points_to_real_job_record
39
+ pipeline = create_pipeline(on_failure_strategy: "halt")
40
+ pipeline.update_columns(status: "running")
41
+ step = build_step(pipeline, key: "step_a", job_class: "DownloadJob")
42
+
43
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step.id])
44
+
45
+ step.reload
46
+ good_job = GoodJob::Job.find_by(id: step.good_job_id)
47
+
48
+ refute_nil good_job, "good_job_id should point to a real GoodJob::Job record"
49
+ assert_equal step.good_job_batch_id, good_job.batch_id
50
+ end
51
+
52
+ # --- batch callback setup ---
53
+
54
+ def test_batch_has_step_finished_callback
55
+ pipeline = create_pipeline(on_failure_strategy: "halt")
56
+ pipeline.update_columns(status: "running")
57
+ step = build_step(pipeline, key: "step_a", job_class: "DownloadJob")
58
+
59
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step.id])
60
+
61
+ batch_record = GoodJob::BatchRecord.find(step.reload.good_job_batch_id)
62
+
63
+ assert_equal "GoodPipeline::StepFinishedJob", batch_record.on_finish
64
+ assert_equal({ step_id: step.id }, batch_record.properties)
65
+ end
66
+
67
+ # --- enqueue_options ---
68
+
69
+ def test_respects_queue_and_priority_options
70
+ pipeline = create_pipeline(on_failure_strategy: "halt")
71
+ pipeline.update_columns(status: "running")
72
+ step = build_step(pipeline, key: "step_a", job_class: "DownloadJob",
73
+ enqueue_options: { "queue" => "critical", "priority" => 3 })
74
+
75
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step.id])
76
+
77
+ good_job = GoodJob::Job.find_by(id: step.reload.good_job_id)
78
+
79
+ assert_equal "critical", good_job.queue_name
80
+ assert_equal 3, good_job.priority
81
+ end
82
+
83
+ def test_respects_wait_option
84
+ pipeline = create_pipeline(on_failure_strategy: "halt")
85
+ pipeline.update_columns(status: "running")
86
+ step = build_step(pipeline, key: "step_a", job_class: "DownloadJob",
87
+ enqueue_options: { "wait" => 300 })
88
+
89
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step.id])
90
+
91
+ good_job = GoodJob::Job.find_by(id: step.reload.good_job_id)
92
+
93
+ refute_nil good_job.scheduled_at
94
+ assert_in_delta 300, good_job.scheduled_at - good_job.created_at, 5
95
+ end
96
+
97
+ def test_passes_step_params_to_job
98
+ pipeline = create_pipeline(on_failure_strategy: "halt")
99
+ pipeline.update_columns(status: "running")
100
+ step = build_step(pipeline, key: "step_a", job_class: "DownloadJob",
101
+ params: { "video_id" => 42 })
102
+
103
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step.id])
104
+
105
+ good_job = GoodJob::Job.find_by(id: step.reload.good_job_id)
106
+ arguments = good_job.serialized_params["arguments"]
107
+
108
+ assert_equal 42, arguments.first["video_id"]
109
+ end
110
+
111
+ def test_handles_empty_params
112
+ pipeline = create_pipeline(on_failure_strategy: "halt")
113
+ pipeline.update_columns(status: "running")
114
+ step = build_step(pipeline, key: "step_a", job_class: "DownloadJob", params: {})
115
+
116
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step.id])
117
+
118
+ assert_equal "enqueued", step.reload.coordination_status
119
+ end
120
+
121
+ # --- guard clauses ---
122
+
123
+ def test_skips_non_pending_steps
124
+ pipeline = create_pipeline(on_failure_strategy: "halt")
125
+ pipeline.update_columns(status: "running")
126
+ step_a = build_step(pipeline, key: "step_a", job_class: "DownloadJob")
127
+ step_a.update_columns(coordination_status: "enqueued")
128
+ step_b = build_step(pipeline, key: "step_b", job_class: "TranscodeJob")
129
+
130
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step_a.id, step_b.id])
131
+
132
+ assert_equal "enqueued", step_b.reload.coordination_status
133
+ refute_nil step_b.good_job_id
134
+ assert_nil step_a.reload.good_job_id, "Non-pending step should not have been re-enqueued"
135
+ end
136
+
137
+ def test_skips_steps_with_good_job_id
138
+ pipeline = create_pipeline(on_failure_strategy: "halt")
139
+ pipeline.update_columns(status: "running")
140
+ step_a = build_step(pipeline, key: "step_a", job_class: "DownloadJob")
141
+ existing_job_id = SecureRandom.uuid
142
+ step_a.update_columns(good_job_id: existing_job_id)
143
+ step_b = build_step(pipeline, key: "step_b", job_class: "TranscodeJob")
144
+
145
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step_a.id, step_b.id])
146
+
147
+ assert_equal "enqueued", step_b.reload.coordination_status
148
+ assert_equal existing_job_id, step_a.reload.good_job_id, "Step with good_job_id should be left alone"
149
+ end
150
+
151
+ def test_handles_empty_array
152
+ # Should not raise
153
+ result = GoodPipeline::Coordinator.bulk_enqueue_steps([])
154
+ assert_nil result
155
+ end
156
+
157
+ # --- branch step fallback ---
158
+
159
+ def test_falls_back_to_try_enqueue_step_for_branch_steps
160
+ pipeline = create_pipeline(on_failure_strategy: "halt")
161
+ pipeline.update_columns(status: "running")
162
+ branch_step = build_step(pipeline, key: "format_check",
163
+ job_class: GoodPipeline::BRANCH_JOB_CLASS)
164
+ branch_step.update_columns(branch: { "decides" => "pick_format", "empty_arms" => %w[hd sd] })
165
+ normal_step = build_step(pipeline, key: "step_a", job_class: "DownloadJob")
166
+
167
+ GoodPipeline::Coordinator.bulk_enqueue_steps([branch_step.id, normal_step.id])
168
+
169
+ assert_equal "enqueued", normal_step.reload.coordination_status
170
+ refute_nil normal_step.good_job_id
171
+
172
+ branch_step.reload
173
+ refute_equal "pending", branch_step.coordination_status,
174
+ "Branch step should have been processed by try_enqueue_step fallback"
175
+ end
176
+
177
+ # --- error handling ---
178
+
179
+ def test_invalid_job_class_fails_step_without_blocking_others
180
+ pipeline = create_pipeline(on_failure_strategy: "halt")
181
+ pipeline.update_columns(status: "running")
182
+ bad_step = build_step(pipeline, key: "bad_step", job_class: "NonExistentJob")
183
+ good_step = build_step(pipeline, key: "good_step", job_class: "DownloadJob")
184
+
185
+ GoodPipeline::Coordinator.bulk_enqueue_steps([bad_step.id, good_step.id])
186
+
187
+ assert_equal "enqueued", good_step.reload.coordination_status
188
+ refute_nil good_step.good_job_id
189
+
190
+ assert_equal "failed", bad_step.reload.coordination_status
191
+ assert_equal "GoodPipeline::ConfigurationError", bad_step.error_class
192
+ end
193
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestQueueConfiguration < ActiveSupport::TestCase
6
+ teardown do
7
+ GoodPipeline.coordination_queue_name = nil
8
+ GoodPipeline.callback_queue_name = nil
9
+ end
10
+
11
+ # --- global defaults ---
12
+
13
+ def test_default_coordination_queue_name
14
+ assert_equal "good_pipeline_coordination", GoodPipeline.coordination_queue_name
15
+ end
16
+
17
+ def test_default_callback_queue_name
18
+ assert_equal "good_pipeline_callbacks", GoodPipeline.callback_queue_name
19
+ end
20
+
21
+ # --- global override ---
22
+
23
+ def test_global_coordination_queue_override
24
+ GoodPipeline.coordination_queue_name = "custom_coordination"
25
+
26
+ assert_equal "custom_coordination", GoodPipeline.coordination_queue_name
27
+ end
28
+
29
+ def test_global_callback_queue_override
30
+ GoodPipeline.callback_queue_name = "custom_callbacks"
31
+
32
+ assert_equal "custom_callbacks", GoodPipeline.callback_queue_name
33
+ end
34
+
35
+ # --- pipeline DSL ---
36
+
37
+ def test_pipeline_dsl_coordination_queue
38
+ klass = Class.new(GoodPipeline::Pipeline) do
39
+ coordination_queue_name "pipeline_coordination"
40
+ def configure(**) = run(:a, DownloadJob)
41
+ end
42
+
43
+ assert_equal "pipeline_coordination", klass.coordination_queue_name
44
+ end
45
+
46
+ def test_pipeline_dsl_callback_queue
47
+ klass = Class.new(GoodPipeline::Pipeline) do
48
+ callback_queue_name "pipeline_callbacks"
49
+ def configure(**) = run(:a, DownloadJob)
50
+ end
51
+
52
+ assert_equal "pipeline_callbacks", klass.callback_queue_name
53
+ end
54
+
55
+ # --- pipeline DSL fallback to global ---
56
+
57
+ def test_pipeline_without_dsl_uses_global_config
58
+ GoodPipeline.coordination_queue_name = "global_coordination"
59
+
60
+ klass = Class.new(GoodPipeline::Pipeline) do
61
+ def configure(**) = run(:a, DownloadJob)
62
+ end
63
+
64
+ assert_equal "global_coordination", klass.coordination_queue_name
65
+ end
66
+
67
+ def test_pipeline_without_dsl_or_global_uses_default
68
+ klass = Class.new(GoodPipeline::Pipeline) do
69
+ def configure(**) = run(:a, DownloadJob)
70
+ end
71
+
72
+ assert_equal "good_pipeline_coordination", klass.coordination_queue_name
73
+ end
74
+
75
+ # --- pipeline DSL overrides global ---
76
+
77
+ def test_pipeline_dsl_overrides_global
78
+ GoodPipeline.coordination_queue_name = "global_coordination"
79
+
80
+ klass = Class.new(GoodPipeline::Pipeline) do
81
+ coordination_queue_name "pipeline_coordination"
82
+ def configure(**) = run(:a, DownloadJob)
83
+ end
84
+
85
+ assert_equal "pipeline_coordination", klass.coordination_queue_name
86
+ end
87
+
88
+ # --- inheritance ---
89
+
90
+ def test_pipeline_inherits_queue_from_parent
91
+ parent = Class.new(GoodPipeline::Pipeline) do
92
+ coordination_queue_name "parent_coordination"
93
+ callback_queue_name "parent_callbacks"
94
+ end
95
+
96
+ child = Class.new(parent) do
97
+ def configure(**) = run(:a, DownloadJob)
98
+ end
99
+
100
+ assert_equal "parent_coordination", child.coordination_queue_name
101
+ assert_equal "parent_callbacks", child.callback_queue_name
102
+ end
103
+
104
+ # --- step batch gets coordination queue ---
105
+
106
+ def test_step_batch_gets_coordination_queue
107
+ klass = Class.new(GoodPipeline::Pipeline) do
108
+ coordination_queue_name "step_coordination"
109
+ def configure(**) = run(:a, DownloadJob)
110
+ end
111
+ klass.define_singleton_method(:name) { "StepBatchQueueTestPipeline" }
112
+ Object.const_set(:StepBatchQueueTestPipeline, klass) unless defined?(::StepBatchQueueTestPipeline)
113
+
114
+ pipeline_record = StepBatchQueueTestPipeline.run
115
+
116
+ step = pipeline_record.steps.first
117
+ batch_record = GoodJob::BatchRecord.find(step.good_job_batch_id)
118
+
119
+ assert_equal "step_coordination", batch_record.callback_queue_name
120
+ end
121
+
122
+ # --- pipeline batch gets coordination queue ---
123
+
124
+ def test_pipeline_batch_gets_coordination_queue
125
+ klass = Class.new(GoodPipeline::Pipeline) do
126
+ coordination_queue_name "pipeline_coordination"
127
+ def configure(**) = run(:a, DownloadJob)
128
+ end
129
+ klass.define_singleton_method(:name) { "PipelineBatchQueueTestPipeline" }
130
+ Object.const_set(:PipelineBatchQueueTestPipeline, klass) unless defined?(::PipelineBatchQueueTestPipeline)
131
+
132
+ pipeline_record = PipelineBatchQueueTestPipeline.run
133
+
134
+ actual_record = GoodPipeline::PipelineRecord.find(pipeline_record.id)
135
+ batch_record = GoodJob::BatchRecord.find(actual_record.good_job_batch_id)
136
+
137
+ assert_equal "pipeline_coordination", batch_record.callback_queue_name
138
+ end
139
+
140
+ # --- PipelineCallbackJob gets callback queue ---
141
+
142
+ def test_callback_job_gets_callback_queue
143
+ klass = Class.new(GoodPipeline::Pipeline) do
144
+ callback_queue_name "my_callbacks"
145
+ def configure(**) = run(:a, DownloadJob)
146
+ end
147
+ klass.define_singleton_method(:name) { "CallbackQueueTestPipeline" }
148
+ Object.const_set(:CallbackQueueTestPipeline, klass) unless defined?(::CallbackQueueTestPipeline)
149
+
150
+ pipeline_record = CallbackQueueTestPipeline.run
151
+ run_pipeline_to_completion(pipeline_record)
152
+
153
+ callback_job = GoodJob::Job.where(job_class: "GoodPipeline::PipelineCallbackJob").last
154
+
155
+ assert_equal "my_callbacks", callback_job.queue_name
156
+ end
157
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestBulkEnqueueEndToEnd < ActiveSupport::TestCase
6
+ def test_fan_in_pipeline_with_multiple_root_steps_succeeds
7
+ pipeline_class = Class.new(GoodPipeline::Pipeline) do
8
+ failure_strategy :halt
9
+
10
+ define_method(:configure) do |**_kwargs|
11
+ run :root_a, DownloadJob
12
+ run :root_b, TranscodeJob
13
+ run :root_c, ThumbnailJob
14
+ run :collector, PublishJob, after: %i[root_a root_b root_c]
15
+ end
16
+ end
17
+ Object.const_set(:FanInBulkTestPipeline, pipeline_class) unless defined?(::FanInBulkTestPipeline)
18
+
19
+ pipeline_record = FanInBulkTestPipeline.run
20
+
21
+ # All 3 root steps should have been enqueued with distinct batches
22
+ root_steps = pipeline_record.steps.where(key: %w[root_a root_b root_c])
23
+ root_steps.each do |step|
24
+ refute_equal "pending", step.coordination_status,
25
+ "Root step #{step.key} should have been enqueued"
26
+ refute_nil step.good_job_batch_id
27
+ refute_nil step.good_job_id
28
+ end
29
+
30
+ batch_ids = root_steps.pluck(:good_job_batch_id).uniq
31
+ assert_equal 3, batch_ids.size, "Each root step should have a unique batch"
32
+
33
+ result = run_pipeline_to_completion(pipeline_record)
34
+
35
+ assert_equal "succeeded", result.status
36
+ assert(result.steps.all? { |step| step.coordination_status == "succeeded" })
37
+ end
38
+
39
+ def test_all_root_steps_pipeline_succeeds
40
+ pipeline_class = Class.new(GoodPipeline::Pipeline) do
41
+ failure_strategy :continue
42
+
43
+ define_method(:configure) do |**_kwargs|
44
+ run :step_a, DownloadJob
45
+ run :step_b, TranscodeJob
46
+ run :step_c, ThumbnailJob
47
+ run :step_d, PublishJob
48
+ run :step_e, CleanupJob
49
+ end
50
+ end
51
+ Object.const_set(:AllRootsBulkTestPipeline, pipeline_class) unless defined?(::AllRootsBulkTestPipeline)
52
+
53
+ pipeline_record = AllRootsBulkTestPipeline.run
54
+ result = run_pipeline_to_completion(pipeline_record)
55
+
56
+ assert_equal "succeeded", result.status
57
+
58
+ result.steps.each do |step|
59
+ assert_equal "succeeded", step.coordination_status
60
+ refute_nil step.good_job_batch_id
61
+ refute_nil step.good_job_id
62
+ end
63
+ end
64
+
65
+ def test_fan_in_with_failing_root_step_halts
66
+ pipeline_class = Class.new(GoodPipeline::Pipeline) do
67
+ failure_strategy :halt
68
+
69
+ define_method(:configure) do |**_kwargs|
70
+ run :root_a, DownloadJob
71
+ run :root_b, FailingJob
72
+ run :root_c, ThumbnailJob
73
+ run :collector, PublishJob, after: %i[root_a root_b root_c]
74
+ end
75
+ end
76
+ Object.const_set(:FanInFailBulkTestPipeline, pipeline_class) unless defined?(::FanInFailBulkTestPipeline)
77
+
78
+ pipeline_record = FanInFailBulkTestPipeline.run
79
+ result = run_pipeline_to_completion(pipeline_record)
80
+
81
+ assert_equal "halted", result.status
82
+ assert_equal "failed", result.steps.find_by(key: "root_b").coordination_status
83
+ end
84
+
85
+ def test_enqueue_options_forwarded_to_good_job
86
+ pipeline_class = Class.new(GoodPipeline::Pipeline) do
87
+ failure_strategy :halt
88
+
89
+ define_method(:configure) do |**_kwargs|
90
+ run :step_a, DownloadJob, enqueue: { queue: "critical", priority: 1 }
91
+ run :step_b, TranscodeJob, enqueue: { queue: "low", priority: 10 }
92
+ end
93
+ end
94
+ Object.const_set(:EnqueueOptionsBulkTestPipeline, pipeline_class) unless defined?(::EnqueueOptionsBulkTestPipeline)
95
+
96
+ pipeline_record = EnqueueOptionsBulkTestPipeline.run
97
+
98
+ step_a = pipeline_record.steps.find_by(key: "step_a")
99
+ step_b = pipeline_record.steps.find_by(key: "step_b")
100
+
101
+ good_job_a = GoodJob::Job.find_by(id: step_a.good_job_id)
102
+ good_job_b = GoodJob::Job.find_by(id: step_b.good_job_id)
103
+
104
+ assert_equal "critical", good_job_a.queue_name
105
+ assert_equal 1, good_job_a.priority
106
+ assert_equal "low", good_job_b.queue_name
107
+ assert_equal 10, good_job_b.priority
108
+ end
109
+ end
@@ -3,21 +3,6 @@
3
3
  require "test_helper"
4
4
 
5
5
  class TestEndToEnd < ActiveSupport::TestCase
6
- def run_pipeline_to_completion(pipeline_record, timeout: 15)
7
- deadline = Time.current + timeout
8
- loop do
9
- perform_enqueued_jobs_inline
10
- pipeline_record.reload
11
- return pipeline_record if pipeline_record.terminal?
12
-
13
- if Time.current > deadline
14
- raise "Pipeline did not reach terminal state within #{timeout}s (status: #{pipeline_record.status})"
15
- end
16
-
17
- sleep 0.05
18
- end
19
- end
20
-
21
6
  def test_full_pipeline_succeeds
22
7
  pipeline_record = VideoProcessingPipeline.run(video_id: 123)
23
8