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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestPipelineRecord < ActiveSupport::TestCase
|
|
6
|
+
# --- Defaults ---
|
|
7
|
+
|
|
8
|
+
def test_default_status_is_pending
|
|
9
|
+
pipeline = create_pipeline
|
|
10
|
+
|
|
11
|
+
assert_equal "pending", pipeline.status
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_default_halt_triggered_is_false
|
|
15
|
+
pipeline = create_pipeline
|
|
16
|
+
|
|
17
|
+
refute pipeline.halt_triggered
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def test_default_params_is_empty_hash
|
|
21
|
+
pipeline = create_pipeline
|
|
22
|
+
|
|
23
|
+
assert_equal({}, pipeline.params)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# --- UUID primary key ---
|
|
27
|
+
|
|
28
|
+
def test_id_is_uuid
|
|
29
|
+
pipeline = create_pipeline
|
|
30
|
+
|
|
31
|
+
assert_match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/, pipeline.id)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# --- STI disabled ---
|
|
35
|
+
|
|
36
|
+
def test_type_column_does_not_trigger_sti
|
|
37
|
+
pipeline = create_pipeline(type: "VideoProcessingPipeline")
|
|
38
|
+
reloaded = GoodPipeline::PipelineRecord.find(pipeline.id)
|
|
39
|
+
|
|
40
|
+
assert_instance_of GoodPipeline::PipelineRecord, reloaded
|
|
41
|
+
assert_equal "VideoProcessingPipeline", reloaded.type
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# --- terminal? ---
|
|
45
|
+
|
|
46
|
+
def test_terminal_returns_false_for_pending
|
|
47
|
+
pipeline = create_pipeline
|
|
48
|
+
|
|
49
|
+
refute_predicate pipeline, :terminal?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_terminal_returns_false_for_running
|
|
53
|
+
pipeline = create_pipeline
|
|
54
|
+
pipeline.update_columns(status: "running")
|
|
55
|
+
|
|
56
|
+
refute_predicate pipeline, :terminal?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_terminal_returns_true_for_succeeded
|
|
60
|
+
pipeline = create_pipeline
|
|
61
|
+
pipeline.update_columns(status: "succeeded")
|
|
62
|
+
|
|
63
|
+
assert_predicate pipeline, :terminal?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def test_terminal_returns_true_for_failed
|
|
67
|
+
pipeline = create_pipeline
|
|
68
|
+
pipeline.update_columns(status: "failed")
|
|
69
|
+
|
|
70
|
+
assert_predicate pipeline, :terminal?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def test_terminal_returns_true_for_halted
|
|
74
|
+
pipeline = create_pipeline
|
|
75
|
+
pipeline.update_columns(status: "halted")
|
|
76
|
+
|
|
77
|
+
assert_predicate pipeline, :terminal?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_terminal_returns_true_for_skipped
|
|
81
|
+
pipeline = create_pipeline
|
|
82
|
+
pipeline.update_columns(status: "skipped")
|
|
83
|
+
|
|
84
|
+
assert_predicate pipeline, :terminal?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- transition_to! valid transitions ---
|
|
88
|
+
|
|
89
|
+
def test_transition_pending_to_running
|
|
90
|
+
pipeline = create_pipeline
|
|
91
|
+
pipeline.transition_to!(:running)
|
|
92
|
+
|
|
93
|
+
assert_equal "running", pipeline.status
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_transition_pending_to_skipped
|
|
97
|
+
pipeline = create_pipeline
|
|
98
|
+
pipeline.transition_to!(:skipped)
|
|
99
|
+
|
|
100
|
+
assert_equal "skipped", pipeline.status
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_transition_running_to_succeeded
|
|
104
|
+
pipeline = create_pipeline
|
|
105
|
+
pipeline.transition_to!(:running)
|
|
106
|
+
pipeline.transition_to!(:succeeded)
|
|
107
|
+
|
|
108
|
+
assert_equal "succeeded", pipeline.status
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def test_transition_running_to_failed
|
|
112
|
+
pipeline = create_pipeline
|
|
113
|
+
pipeline.transition_to!(:running)
|
|
114
|
+
pipeline.transition_to!(:failed)
|
|
115
|
+
|
|
116
|
+
assert_equal "failed", pipeline.status
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def test_transition_running_to_halted
|
|
120
|
+
pipeline = create_pipeline
|
|
121
|
+
pipeline.transition_to!(:running)
|
|
122
|
+
pipeline.transition_to!(:halted)
|
|
123
|
+
|
|
124
|
+
assert_equal "halted", pipeline.status
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# --- transition_to! invalid transitions ---
|
|
128
|
+
|
|
129
|
+
def test_transition_pending_to_succeeded_raises
|
|
130
|
+
pipeline = create_pipeline
|
|
131
|
+
error = assert_raises(GoodPipeline::InvalidTransition) { pipeline.transition_to!(:succeeded) }
|
|
132
|
+
assert_includes error.message, "cannot transition pipeline from 'pending' to 'succeeded'"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def test_transition_pending_to_failed_raises
|
|
136
|
+
pipeline = create_pipeline
|
|
137
|
+
error = assert_raises(GoodPipeline::InvalidTransition) { pipeline.transition_to!(:failed) }
|
|
138
|
+
assert_includes error.message, "from 'pending' to 'failed'"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def test_transition_running_to_pending_raises
|
|
142
|
+
pipeline = create_pipeline
|
|
143
|
+
pipeline.transition_to!(:running)
|
|
144
|
+
assert_raises(GoodPipeline::InvalidTransition) { pipeline.transition_to!(:pending) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def test_transition_from_terminal_succeeded_raises
|
|
148
|
+
pipeline = create_pipeline
|
|
149
|
+
pipeline.update_columns(status: "succeeded")
|
|
150
|
+
pipeline.reload
|
|
151
|
+
assert_raises(GoodPipeline::InvalidTransition) { pipeline.transition_to!(:running) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def test_transition_from_terminal_failed_raises
|
|
155
|
+
pipeline = create_pipeline
|
|
156
|
+
pipeline.update_columns(status: "failed")
|
|
157
|
+
pipeline.reload
|
|
158
|
+
assert_raises(GoodPipeline::InvalidTransition) { pipeline.transition_to!(:running) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def test_transition_from_terminal_halted_raises
|
|
162
|
+
pipeline = create_pipeline
|
|
163
|
+
pipeline.update_columns(status: "halted")
|
|
164
|
+
pipeline.reload
|
|
165
|
+
assert_raises(GoodPipeline::InvalidTransition) { pipeline.transition_to!(:running) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def test_transition_from_terminal_skipped_raises
|
|
169
|
+
pipeline = create_pipeline
|
|
170
|
+
pipeline.update_columns(status: "skipped")
|
|
171
|
+
pipeline.reload
|
|
172
|
+
assert_raises(GoodPipeline::InvalidTransition) { pipeline.transition_to!(:running) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# --- transition_to! accepts symbols ---
|
|
176
|
+
|
|
177
|
+
def test_transition_to_accepts_symbols
|
|
178
|
+
pipeline = create_pipeline
|
|
179
|
+
pipeline.transition_to!(:running)
|
|
180
|
+
|
|
181
|
+
assert_equal "running", pipeline.status
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestRunner < ActiveSupport::TestCase
|
|
6
|
+
TestPipeline = Class.new(GoodPipeline::Pipeline) do
|
|
7
|
+
description "Test pipeline"
|
|
8
|
+
failure_strategy :halt
|
|
9
|
+
|
|
10
|
+
define_method(:configure) do |video_id:, **|
|
|
11
|
+
run :download, DownloadJob, with: { video_id: video_id }
|
|
12
|
+
run :transcode, TranscodeJob, after: :download
|
|
13
|
+
run :thumbnail, ThumbnailJob, after: :download
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_creates_pipeline_record
|
|
18
|
+
klass = TestPipeline
|
|
19
|
+
instance = klass.build(video_id: 42)
|
|
20
|
+
|
|
21
|
+
record = GoodPipeline::Runner.call(instance)
|
|
22
|
+
|
|
23
|
+
assert_instance_of GoodPipeline::PipelineRecord, record
|
|
24
|
+
assert_equal klass.name, record.type
|
|
25
|
+
assert_equal({ "video_id" => 42 }, record.params)
|
|
26
|
+
assert_equal "running", record.status
|
|
27
|
+
assert_equal "halt", record.on_failure_strategy
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_creates_step_records
|
|
31
|
+
klass = TestPipeline
|
|
32
|
+
instance = klass.build(video_id: 42)
|
|
33
|
+
|
|
34
|
+
record = GoodPipeline::Runner.call(instance)
|
|
35
|
+
|
|
36
|
+
steps = record.steps.order(:key)
|
|
37
|
+
|
|
38
|
+
assert_equal 3, steps.count
|
|
39
|
+
assert_equal %w[download thumbnail transcode], steps.map(&:key)
|
|
40
|
+
assert_equal "DownloadJob", steps.find_by(key: "download").job_class
|
|
41
|
+
assert_equal({ "video_id" => 42 }, steps.find_by(key: "download").params)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_creates_dependency_records
|
|
45
|
+
klass = TestPipeline
|
|
46
|
+
instance = klass.build(video_id: 42)
|
|
47
|
+
|
|
48
|
+
record = GoodPipeline::Runner.call(instance)
|
|
49
|
+
|
|
50
|
+
deps = record.dependencies
|
|
51
|
+
|
|
52
|
+
assert_equal 2, deps.count
|
|
53
|
+
|
|
54
|
+
download = record.steps.find_by(key: "download")
|
|
55
|
+
transcode = record.steps.find_by(key: "transcode")
|
|
56
|
+
thumbnail = record.steps.find_by(key: "thumbnail")
|
|
57
|
+
|
|
58
|
+
transcode_dep = deps.find_by(step: transcode)
|
|
59
|
+
|
|
60
|
+
assert_equal download.id, transcode_dep.depends_on_step_id
|
|
61
|
+
|
|
62
|
+
thumbnail_dep = deps.find_by(step: thumbnail)
|
|
63
|
+
|
|
64
|
+
assert_equal download.id, thumbnail_dep.depends_on_step_id
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_enqueues_root_steps
|
|
68
|
+
klass = TestPipeline
|
|
69
|
+
instance = klass.build(video_id: 42)
|
|
70
|
+
|
|
71
|
+
record = GoodPipeline::Runner.call(instance)
|
|
72
|
+
|
|
73
|
+
download = record.steps.find_by(key: "download")
|
|
74
|
+
# Root step should have been enqueued (may have already completed)
|
|
75
|
+
refute_equal "pending", download.reload.coordination_status
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_stores_pipeline_batch_id
|
|
79
|
+
klass = TestPipeline
|
|
80
|
+
instance = klass.build(video_id: 42)
|
|
81
|
+
|
|
82
|
+
record = GoodPipeline::Runner.call(instance)
|
|
83
|
+
|
|
84
|
+
assert_not_nil record.good_job_batch_id
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestStepFinishedJob < ActiveSupport::TestCase
|
|
6
|
+
def test_completes_step_as_succeeded_via_batch
|
|
7
|
+
pipeline = create_pipeline
|
|
8
|
+
pipeline.update_columns(status: "running")
|
|
9
|
+
step = create_step(pipeline, key: "a")
|
|
10
|
+
step.update_columns(coordination_status: "enqueued")
|
|
11
|
+
|
|
12
|
+
batch = GoodJob::Batch.new
|
|
13
|
+
batch.properties = { step_id: step.id }
|
|
14
|
+
batch.enqueue {}
|
|
15
|
+
|
|
16
|
+
GoodPipeline::StepFinishedJob.new.perform(batch, {})
|
|
17
|
+
|
|
18
|
+
assert_equal "succeeded", step.reload.coordination_status
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_completes_step_as_failed_when_batch_discarded
|
|
22
|
+
pipeline = create_pipeline
|
|
23
|
+
pipeline.update_columns(status: "running")
|
|
24
|
+
step = create_step(pipeline, key: "a")
|
|
25
|
+
step.update_columns(coordination_status: "enqueued")
|
|
26
|
+
|
|
27
|
+
batch = GoodJob::Batch.new
|
|
28
|
+
batch.properties = { step_id: step.id }
|
|
29
|
+
batch.enqueue {}
|
|
30
|
+
GoodJob::BatchRecord.where(id: batch.id).update_all(discarded_at: Time.current)
|
|
31
|
+
batch = GoodJob::Batch.find(batch.id)
|
|
32
|
+
|
|
33
|
+
GoodPipeline::StepFinishedJob.new.perform(batch, {})
|
|
34
|
+
|
|
35
|
+
assert_equal "failed", step.reload.coordination_status
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestStepRecord < ActiveSupport::TestCase
|
|
6
|
+
# --- Defaults ---
|
|
7
|
+
|
|
8
|
+
def test_default_coordination_status_is_pending
|
|
9
|
+
pipeline = create_pipeline
|
|
10
|
+
step = create_step(pipeline)
|
|
11
|
+
|
|
12
|
+
assert_equal "pending", step.coordination_status
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# --- UUID primary key ---
|
|
16
|
+
|
|
17
|
+
def test_id_is_uuid
|
|
18
|
+
pipeline = create_pipeline
|
|
19
|
+
step = create_step(pipeline)
|
|
20
|
+
|
|
21
|
+
assert_match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/, step.id)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# --- terminal_coordination_status? ---
|
|
25
|
+
|
|
26
|
+
def test_terminal_returns_false_for_pending
|
|
27
|
+
pipeline = create_pipeline
|
|
28
|
+
step = create_step(pipeline)
|
|
29
|
+
|
|
30
|
+
refute_predicate step, :terminal_coordination_status?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_terminal_returns_false_for_enqueued
|
|
34
|
+
pipeline = create_pipeline
|
|
35
|
+
step = create_step(pipeline)
|
|
36
|
+
step.update_columns(coordination_status: "enqueued")
|
|
37
|
+
|
|
38
|
+
refute_predicate step, :terminal_coordination_status?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_terminal_returns_true_for_succeeded
|
|
42
|
+
pipeline = create_pipeline
|
|
43
|
+
step = create_step(pipeline)
|
|
44
|
+
step.update_columns(coordination_status: "succeeded")
|
|
45
|
+
|
|
46
|
+
assert_predicate step, :terminal_coordination_status?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_terminal_returns_true_for_failed
|
|
50
|
+
pipeline = create_pipeline
|
|
51
|
+
step = create_step(pipeline)
|
|
52
|
+
step.update_columns(coordination_status: "failed")
|
|
53
|
+
|
|
54
|
+
assert_predicate step, :terminal_coordination_status?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_terminal_returns_true_for_skipped
|
|
58
|
+
pipeline = create_pipeline
|
|
59
|
+
step = create_step(pipeline)
|
|
60
|
+
step.update_columns(coordination_status: "skipped")
|
|
61
|
+
|
|
62
|
+
assert_predicate step, :terminal_coordination_status?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# --- transition_coordination_status_to! valid transitions ---
|
|
66
|
+
|
|
67
|
+
def test_transition_pending_to_enqueued
|
|
68
|
+
pipeline = create_pipeline
|
|
69
|
+
step = create_step(pipeline)
|
|
70
|
+
step.transition_coordination_status_to!(:enqueued)
|
|
71
|
+
|
|
72
|
+
assert_equal "enqueued", step.coordination_status
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_transition_pending_to_skipped
|
|
76
|
+
pipeline = create_pipeline
|
|
77
|
+
step = create_step(pipeline)
|
|
78
|
+
step.transition_coordination_status_to!(:skipped)
|
|
79
|
+
|
|
80
|
+
assert_equal "skipped", step.coordination_status
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_transition_enqueued_to_succeeded
|
|
84
|
+
pipeline = create_pipeline
|
|
85
|
+
step = create_step(pipeline)
|
|
86
|
+
step.transition_coordination_status_to!(:enqueued)
|
|
87
|
+
step.transition_coordination_status_to!(:succeeded)
|
|
88
|
+
|
|
89
|
+
assert_equal "succeeded", step.coordination_status
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_transition_enqueued_to_failed
|
|
93
|
+
pipeline = create_pipeline
|
|
94
|
+
step = create_step(pipeline)
|
|
95
|
+
step.transition_coordination_status_to!(:enqueued)
|
|
96
|
+
step.transition_coordination_status_to!(:failed)
|
|
97
|
+
|
|
98
|
+
assert_equal "failed", step.coordination_status
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- transition_coordination_status_to! invalid transitions ---
|
|
102
|
+
|
|
103
|
+
def test_transition_pending_to_succeeded_raises
|
|
104
|
+
pipeline = create_pipeline
|
|
105
|
+
step = create_step(pipeline)
|
|
106
|
+
assert_raises(GoodPipeline::InvalidTransition) do
|
|
107
|
+
step.transition_coordination_status_to!(:succeeded)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def test_transition_pending_to_failed_raises
|
|
112
|
+
pipeline = create_pipeline
|
|
113
|
+
step = create_step(pipeline)
|
|
114
|
+
assert_raises(GoodPipeline::InvalidTransition) do
|
|
115
|
+
step.transition_coordination_status_to!(:failed)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def test_transition_enqueued_to_pending_raises
|
|
120
|
+
pipeline = create_pipeline
|
|
121
|
+
step = create_step(pipeline)
|
|
122
|
+
step.transition_coordination_status_to!(:enqueued)
|
|
123
|
+
assert_raises(GoodPipeline::InvalidTransition) do
|
|
124
|
+
step.transition_coordination_status_to!(:pending)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def test_transition_enqueued_to_skipped_raises
|
|
129
|
+
pipeline = create_pipeline
|
|
130
|
+
step = create_step(pipeline)
|
|
131
|
+
step.transition_coordination_status_to!(:enqueued)
|
|
132
|
+
assert_raises(GoodPipeline::InvalidTransition) do
|
|
133
|
+
step.transition_coordination_status_to!(:skipped)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def test_transition_from_terminal_succeeded_raises
|
|
138
|
+
pipeline = create_pipeline
|
|
139
|
+
step = create_step(pipeline)
|
|
140
|
+
step.update_columns(coordination_status: "succeeded")
|
|
141
|
+
step.reload
|
|
142
|
+
assert_raises(GoodPipeline::InvalidTransition) do
|
|
143
|
+
step.transition_coordination_status_to!(:enqueued)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def test_transition_from_terminal_failed_raises
|
|
148
|
+
pipeline = create_pipeline
|
|
149
|
+
step = create_step(pipeline)
|
|
150
|
+
step.update_columns(coordination_status: "failed")
|
|
151
|
+
step.reload
|
|
152
|
+
assert_raises(GoodPipeline::InvalidTransition) do
|
|
153
|
+
step.transition_coordination_status_to!(:enqueued)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def test_transition_from_terminal_skipped_raises
|
|
158
|
+
pipeline = create_pipeline
|
|
159
|
+
step = create_step(pipeline)
|
|
160
|
+
step.update_columns(coordination_status: "skipped")
|
|
161
|
+
step.reload
|
|
162
|
+
assert_raises(GoodPipeline::InvalidTransition) do
|
|
163
|
+
step.transition_coordination_status_to!(:enqueued)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# --- Error message includes step key ---
|
|
168
|
+
|
|
169
|
+
def test_error_message_includes_step_key
|
|
170
|
+
pipeline = create_pipeline
|
|
171
|
+
step = create_step(pipeline, key: "transcode")
|
|
172
|
+
error = assert_raises(GoodPipeline::InvalidTransition) do
|
|
173
|
+
step.transition_coordination_status_to!(:succeeded)
|
|
174
|
+
end
|
|
175
|
+
assert_includes error.message, "transcode"
|
|
176
|
+
assert_includes error.message, "from 'pending' to 'succeeded'"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# --- Accepts symbols ---
|
|
180
|
+
|
|
181
|
+
def test_transition_accepts_symbols
|
|
182
|
+
pipeline = create_pipeline
|
|
183
|
+
step = create_step(pipeline)
|
|
184
|
+
step.transition_coordination_status_to!(:enqueued)
|
|
185
|
+
|
|
186
|
+
assert_equal "enqueued", step.coordination_status
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# --- Unique constraint on (pipeline_id, key) ---
|
|
190
|
+
|
|
191
|
+
def test_duplicate_key_within_pipeline_raises
|
|
192
|
+
pipeline = create_pipeline
|
|
193
|
+
create_step(pipeline, key: "download")
|
|
194
|
+
assert_raises(ActiveRecord::RecordNotUnique) do
|
|
195
|
+
create_step(pipeline, key: "download", job_class: "OtherJob")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def test_same_key_in_different_pipelines_allowed
|
|
200
|
+
pipeline_a = create_pipeline(type: "PipelineA")
|
|
201
|
+
pipeline_b = create_pipeline(type: "PipelineB")
|
|
202
|
+
create_step(pipeline_a, key: "download")
|
|
203
|
+
create_step(pipeline_b, key: "download")
|
|
204
|
+
|
|
205
|
+
assert_equal 1, pipeline_a.steps.count
|
|
206
|
+
assert_equal 1, pipeline_b.steps.count
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestConcurrentFanIn < ActiveSupport::TestCase
|
|
6
|
+
def test_simultaneous_upstream_completion_enqueues_downstream_exactly_once
|
|
7
|
+
50.times do |iteration|
|
|
8
|
+
pipeline = create_pipeline(on_failure_strategy: "halt")
|
|
9
|
+
pipeline.update_columns(status: "running")
|
|
10
|
+
step_a = build_step(pipeline, key: "a")
|
|
11
|
+
step_b = build_step(pipeline, key: "b")
|
|
12
|
+
step_c = build_step(pipeline, key: "c", dependencies: [step_a, step_b])
|
|
13
|
+
step_a.update_columns(coordination_status: "enqueued")
|
|
14
|
+
step_b.update_columns(coordination_status: "enqueued")
|
|
15
|
+
|
|
16
|
+
latch = Concurrent::CountDownLatch.new(2)
|
|
17
|
+
|
|
18
|
+
promise_a = rails_promise do
|
|
19
|
+
latch.count_down
|
|
20
|
+
latch.wait(5)
|
|
21
|
+
GoodPipeline::Coordinator.complete_step(step_a.reload, succeeded: true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
promise_b = rails_promise do
|
|
25
|
+
latch.count_down
|
|
26
|
+
latch.wait(5)
|
|
27
|
+
GoodPipeline::Coordinator.complete_step(step_b.reload, succeeded: true)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
promise_a.value!
|
|
31
|
+
promise_b.value!
|
|
32
|
+
|
|
33
|
+
step_c.reload
|
|
34
|
+
|
|
35
|
+
refute_equal "pending", step_c.coordination_status,
|
|
36
|
+
"Iteration #{iteration}: step_c should not still be pending"
|
|
37
|
+
|
|
38
|
+
ActiveRecord::Base.connection.truncate_tables(*ActiveRecord::Base.connection.tables)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_triple_fan_in_enqueues_downstream_exactly_once
|
|
43
|
+
pipeline = create_pipeline(on_failure_strategy: "halt")
|
|
44
|
+
pipeline.update_columns(status: "running")
|
|
45
|
+
step_a = build_step(pipeline, key: "a")
|
|
46
|
+
step_b = build_step(pipeline, key: "b")
|
|
47
|
+
step_c = build_step(pipeline, key: "c")
|
|
48
|
+
step_d = build_step(pipeline, key: "d", dependencies: [step_a, step_b, step_c])
|
|
49
|
+
[step_a, step_b, step_c].each { |step| step.update_columns(coordination_status: "enqueued") }
|
|
50
|
+
|
|
51
|
+
latch = Concurrent::CountDownLatch.new(3)
|
|
52
|
+
|
|
53
|
+
promises = [step_a, step_b, step_c].map do |step|
|
|
54
|
+
rails_promise do
|
|
55
|
+
latch.count_down
|
|
56
|
+
latch.wait(5)
|
|
57
|
+
GoodPipeline::Coordinator.complete_step(step.reload, succeeded: true)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
promises.each(&:value!)
|
|
62
|
+
|
|
63
|
+
step_d.reload
|
|
64
|
+
|
|
65
|
+
refute_equal "pending", step_d.coordination_status
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def test_good_job_id_guard_prevents_double_enqueue
|
|
69
|
+
pipeline = create_pipeline(on_failure_strategy: "halt")
|
|
70
|
+
pipeline.update_columns(status: "running")
|
|
71
|
+
step = build_step(pipeline, key: "a")
|
|
72
|
+
fake_job_id = SecureRandom.uuid
|
|
73
|
+
step.update_column(:good_job_id, fake_job_id)
|
|
74
|
+
|
|
75
|
+
GoodPipeline::Coordinator.try_enqueue_step(step.id)
|
|
76
|
+
|
|
77
|
+
step.reload
|
|
78
|
+
|
|
79
|
+
assert_equal "pending", step.coordination_status
|
|
80
|
+
assert_equal fake_job_id, step.good_job_id
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_coordinator_interleave_enqueues_only_once
|
|
84
|
+
pipeline = create_pipeline(on_failure_strategy: "halt")
|
|
85
|
+
pipeline.update_columns(status: "running")
|
|
86
|
+
step = build_step(pipeline, key: "a")
|
|
87
|
+
|
|
88
|
+
latch = Concurrent::CountDownLatch.new(2)
|
|
89
|
+
|
|
90
|
+
promise_1 = rails_promise do
|
|
91
|
+
latch.count_down
|
|
92
|
+
latch.wait(5)
|
|
93
|
+
GoodPipeline::Coordinator.try_enqueue_step(step.id)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
promise_2 = rails_promise do
|
|
97
|
+
latch.count_down
|
|
98
|
+
latch.wait(5)
|
|
99
|
+
GoodPipeline::Coordinator.try_enqueue_step(step.id)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
promise_1.value!
|
|
103
|
+
promise_2.value!
|
|
104
|
+
|
|
105
|
+
step.reload
|
|
106
|
+
|
|
107
|
+
refute_equal "pending", step.coordination_status
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
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
|
+
def test_full_pipeline_succeeds
|
|
22
|
+
pipeline_record = VideoProcessingPipeline.run(video_id: 123)
|
|
23
|
+
|
|
24
|
+
assert_instance_of GoodPipeline::Chain, pipeline_record
|
|
25
|
+
assert_equal "VideoProcessingPipeline", pipeline_record.type
|
|
26
|
+
assert_equal({ "video_id" => 123 }, pipeline_record.params)
|
|
27
|
+
assert_equal 5, pipeline_record.steps.count
|
|
28
|
+
assert_equal 5, pipeline_record.dependencies.count
|
|
29
|
+
|
|
30
|
+
result = run_pipeline_to_completion(pipeline_record)
|
|
31
|
+
|
|
32
|
+
assert_equal "succeeded", result.status
|
|
33
|
+
assert(result.steps.all? { |step| step.coordination_status == "succeeded" })
|
|
34
|
+
refute_nil result.callbacks_dispatched_at
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def test_pipeline_with_failing_step_halts
|
|
38
|
+
failing_pipeline_class = Class.new(GoodPipeline::Pipeline) do
|
|
39
|
+
failure_strategy :halt
|
|
40
|
+
|
|
41
|
+
define_method(:configure) do |**_kwargs|
|
|
42
|
+
run :step_a, FailingJob
|
|
43
|
+
run :step_b, DownloadJob, after: :step_a
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
Object.const_set(:HaltTestPipeline, failing_pipeline_class) unless defined?(::HaltTestPipeline)
|
|
47
|
+
|
|
48
|
+
pipeline_record = HaltTestPipeline.run
|
|
49
|
+
|
|
50
|
+
result = run_pipeline_to_completion(pipeline_record)
|
|
51
|
+
|
|
52
|
+
assert_equal "halted", result.status
|
|
53
|
+
assert_predicate result, :halt_triggered?
|
|
54
|
+
|
|
55
|
+
step_a = result.steps.find_by(key: "step_a")
|
|
56
|
+
step_b = result.steps.find_by(key: "step_b")
|
|
57
|
+
|
|
58
|
+
assert_equal "failed", step_a.coordination_status
|
|
59
|
+
assert_equal "skipped", step_b.coordination_status
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_pipeline_with_continue_strategy
|
|
63
|
+
continue_pipeline_class = Class.new(GoodPipeline::Pipeline) do
|
|
64
|
+
failure_strategy :continue
|
|
65
|
+
|
|
66
|
+
define_method(:configure) do |**_kwargs|
|
|
67
|
+
run :step_a, FailingJob
|
|
68
|
+
run :step_b, DownloadJob
|
|
69
|
+
run :step_c, DownloadJob, after: :step_a
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
Object.const_set(:ContinueTestPipeline, continue_pipeline_class) unless defined?(::ContinueTestPipeline)
|
|
73
|
+
|
|
74
|
+
pipeline_record = ContinueTestPipeline.run
|
|
75
|
+
|
|
76
|
+
result = run_pipeline_to_completion(pipeline_record)
|
|
77
|
+
|
|
78
|
+
assert_equal "failed", result.status
|
|
79
|
+
refute_predicate result, :halt_triggered?
|
|
80
|
+
|
|
81
|
+
step_a = result.steps.find_by(key: "step_a")
|
|
82
|
+
step_b = result.steps.find_by(key: "step_b")
|
|
83
|
+
step_c = result.steps.find_by(key: "step_c")
|
|
84
|
+
|
|
85
|
+
assert_equal "failed", step_a.coordination_status
|
|
86
|
+
assert_equal "succeeded", step_b.coordination_status
|
|
87
|
+
assert_equal "skipped", step_c.coordination_status
|
|
88
|
+
end
|
|
89
|
+
end
|