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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +1 -1
- data/app/helpers/good_pipeline/mermaid_diagram_builder.rb +1 -0
- data/app/models/good_pipeline/step_record.rb +5 -4
- data/demo/app/jobs/halt_execution_job.rb +7 -0
- data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +2 -0
- data/demo/test/good_pipeline/test_bulk_enqueue.rb +193 -0
- data/demo/test/good_pipeline/test_queue_configuration.rb +157 -0
- data/demo/test/integration/test_bulk_enqueue_end_to_end.rb +109 -0
- data/demo/test/integration/test_end_to_end.rb +0 -15
- data/demo/test/integration/test_halt_execution.rb +100 -0
- data/demo/test/integration/test_queue_configuration.rb +82 -0
- data/demo/test/test_helper.rb +15 -0
- data/docs/architecture.md +8 -0
- data/docs/branching.md +2 -0
- data/docs/callbacks.md +2 -0
- data/docs/defining-pipelines.md +4 -0
- data/docs/failure-strategies.md +35 -0
- data/docs/getting-started.md +12 -0
- data/docs/index.md +1 -1
- data/docs/introduction.md +19 -1
- data/docs/pipeline-chaining.md +2 -0
- data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +2 -0
- data/lib/good_pipeline/chain_coordinator.rb +1 -5
- data/lib/good_pipeline/coordinator.rb +117 -16
- data/lib/good_pipeline/engine.rb +6 -0
- data/lib/good_pipeline/haltable.rb +10 -0
- data/lib/good_pipeline/pipeline.rb +24 -1
- data/lib/good_pipeline/runner.rb +3 -3
- data/lib/good_pipeline/version.rb +1 -1
- data/lib/good_pipeline.rb +16 -0
- metadata +10 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38ec7eb9fb3cec2b9109b9695b0084aff2fa111440cd8fb76dbe53be59bd8e06
|
|
4
|
+
data.tar.gz: 71094436abc0d2c393b3d188510608bf714867201f2fc13eac4cc218d6b8a420
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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" }
|
|
@@ -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
|
|