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,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestEnqueueAtomicity < ActiveSupport::TestCase
|
|
6
|
+
def test_rollback_cancels_step_transition_and_job_record
|
|
7
|
+
pipeline = create_pipeline(on_failure_strategy: "halt")
|
|
8
|
+
pipeline.update_columns(status: "running")
|
|
9
|
+
step = build_step(pipeline, key: "a")
|
|
10
|
+
|
|
11
|
+
GoodPipeline::StepRecord.transaction do
|
|
12
|
+
GoodPipeline::Coordinator.try_enqueue_step(step.id)
|
|
13
|
+
raise ActiveRecord::Rollback
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
step.reload
|
|
17
|
+
|
|
18
|
+
assert_equal "pending", step.coordination_status
|
|
19
|
+
assert_nil step.good_job_id
|
|
20
|
+
assert_nil step.good_job_batch_id
|
|
21
|
+
assert_equal 0, GoodJob::Job.count
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_step_can_be_re_enqueued_after_rollback
|
|
25
|
+
pipeline = create_pipeline(on_failure_strategy: "halt")
|
|
26
|
+
pipeline.update_columns(status: "running")
|
|
27
|
+
step = build_step(pipeline, key: "a")
|
|
28
|
+
|
|
29
|
+
GoodPipeline::StepRecord.transaction do
|
|
30
|
+
GoodPipeline::Coordinator.try_enqueue_step(step.id)
|
|
31
|
+
raise ActiveRecord::Rollback
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
assert_equal "pending", step.reload.coordination_status
|
|
35
|
+
|
|
36
|
+
GoodPipeline::Coordinator.try_enqueue_step(step.id)
|
|
37
|
+
|
|
38
|
+
step.reload
|
|
39
|
+
|
|
40
|
+
refute_equal "pending", step.coordination_status
|
|
41
|
+
assert_not_nil step.good_job_id
|
|
42
|
+
assert_not_nil step.good_job_batch_id
|
|
43
|
+
assert GoodJob::Job.exists?(id: step.good_job_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_good_job_id_and_record_exist_together
|
|
47
|
+
pipeline = create_pipeline(on_failure_strategy: "halt")
|
|
48
|
+
pipeline.update_columns(status: "running")
|
|
49
|
+
step = build_step(pipeline, key: "a")
|
|
50
|
+
|
|
51
|
+
GoodPipeline::Coordinator.try_enqueue_step(step.id)
|
|
52
|
+
|
|
53
|
+
step.reload
|
|
54
|
+
|
|
55
|
+
assert_not_nil step.good_job_id, "good_job_id should be set after enqueue"
|
|
56
|
+
assert GoodJob::Job.exists?(id: step.good_job_id),
|
|
57
|
+
"GoodJob::Job record should exist for the good_job_id"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestPipelineChaining < ActiveSupport::TestCase
|
|
6
|
+
def run_all_to_completion(pipeline_records, timeout: 15)
|
|
7
|
+
deadline = Time.current + timeout
|
|
8
|
+
loop do
|
|
9
|
+
perform_enqueued_jobs_inline
|
|
10
|
+
pipeline_records.each(&:reload)
|
|
11
|
+
return pipeline_records if pipeline_records.all?(&:terminal?)
|
|
12
|
+
|
|
13
|
+
if Time.current > deadline
|
|
14
|
+
statuses = pipeline_records.map { |pipeline| "#{pipeline.type}=#{pipeline.status}" }.join(", ")
|
|
15
|
+
raise "Pipelines did not reach terminal state within #{timeout}s (#{statuses})"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sleep 0.05
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# --- Serial chain ---
|
|
23
|
+
|
|
24
|
+
def test_serial_chain_all_succeed
|
|
25
|
+
TestPipeline.run(video_id: 1)
|
|
26
|
+
.then(NotificationPipeline, with: { video_id: 1 })
|
|
27
|
+
.then(ArchivePipeline, with: { video_id: 1 })
|
|
28
|
+
|
|
29
|
+
all_records = GoodPipeline::PipelineRecord.all.to_a
|
|
30
|
+
run_all_to_completion(all_records)
|
|
31
|
+
|
|
32
|
+
assert all_records.all?(&:succeeded?), "All pipelines should have succeeded"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_serial_chain_first_fails_skips_rest
|
|
36
|
+
HaltTestPipeline.run
|
|
37
|
+
.then(NotificationPipeline, with: {})
|
|
38
|
+
|
|
39
|
+
all_records = GoodPipeline::PipelineRecord.all.to_a
|
|
40
|
+
run_all_to_completion(all_records)
|
|
41
|
+
|
|
42
|
+
upstream = all_records.find { |pipeline| pipeline.type == "HaltTestPipeline" }
|
|
43
|
+
downstream = all_records.find { |pipeline| pipeline.type == "NotificationPipeline" }
|
|
44
|
+
|
|
45
|
+
assert_equal "halted", upstream.status
|
|
46
|
+
assert_equal "skipped", downstream.status
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# --- Fan-out ---
|
|
50
|
+
|
|
51
|
+
def test_fan_out_both_start_after_upstream_succeeds
|
|
52
|
+
TestPipeline.run(video_id: 1)
|
|
53
|
+
.then(
|
|
54
|
+
[NotificationPipeline, { with: {} }],
|
|
55
|
+
[AnalyticsPipeline, { with: {} }]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
all_records = GoodPipeline::PipelineRecord.all.to_a
|
|
59
|
+
run_all_to_completion(all_records)
|
|
60
|
+
|
|
61
|
+
notification = all_records.find { |pipeline| pipeline.type == "NotificationPipeline" }
|
|
62
|
+
analytics = all_records.find { |pipeline| pipeline.type == "AnalyticsPipeline" }
|
|
63
|
+
|
|
64
|
+
assert_equal "succeeded", notification.status
|
|
65
|
+
assert_equal "succeeded", analytics.status
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def test_fan_out_upstream_fails_skips_all_downstream
|
|
69
|
+
HaltTestPipeline.run
|
|
70
|
+
.then(
|
|
71
|
+
[NotificationPipeline, { with: {} }],
|
|
72
|
+
[AnalyticsPipeline, { with: {} }]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
all_records = GoodPipeline::PipelineRecord.all.to_a
|
|
76
|
+
run_all_to_completion(all_records)
|
|
77
|
+
|
|
78
|
+
notification = all_records.find { |pipeline| pipeline.type == "NotificationPipeline" }
|
|
79
|
+
analytics = all_records.find { |pipeline| pipeline.type == "AnalyticsPipeline" }
|
|
80
|
+
|
|
81
|
+
assert_equal "skipped", notification.status
|
|
82
|
+
assert_equal "skipped", analytics.status
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- Fan-in ---
|
|
86
|
+
|
|
87
|
+
def test_fan_in_waits_for_all_upstreams
|
|
88
|
+
GoodPipeline.run(
|
|
89
|
+
[TestPipeline, { with: { video_id: 1 } }],
|
|
90
|
+
[NotificationPipeline, { with: {} }]
|
|
91
|
+
).then(ArchivePipeline, with: {})
|
|
92
|
+
|
|
93
|
+
all_records = GoodPipeline::PipelineRecord.all.to_a
|
|
94
|
+
run_all_to_completion(all_records)
|
|
95
|
+
|
|
96
|
+
archive = all_records.find { |pipeline| pipeline.type == "ArchivePipeline" }
|
|
97
|
+
|
|
98
|
+
assert_equal "succeeded", archive.status
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def test_fan_in_skips_when_any_upstream_fails
|
|
102
|
+
failing_pipeline_class = Class.new(GoodPipeline::Pipeline) do
|
|
103
|
+
failure_strategy :halt
|
|
104
|
+
define_method(:configure) { |**_kwargs| run :fail_step, FailingJob }
|
|
105
|
+
end
|
|
106
|
+
Object.const_set(:FanInFailPipeline, failing_pipeline_class) unless defined?(::FanInFailPipeline)
|
|
107
|
+
|
|
108
|
+
GoodPipeline.run(
|
|
109
|
+
[TestPipeline, { with: { video_id: 1 } }],
|
|
110
|
+
[FanInFailPipeline, { with: {} }]
|
|
111
|
+
).then(ArchivePipeline, with: {})
|
|
112
|
+
|
|
113
|
+
all_records = GoodPipeline::PipelineRecord.all.to_a
|
|
114
|
+
run_all_to_completion(all_records)
|
|
115
|
+
|
|
116
|
+
archive = all_records.find { |pipeline| pipeline.type == "ArchivePipeline" }
|
|
117
|
+
|
|
118
|
+
assert_equal "skipped", archive.status
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- Fan-out then fan-in ---
|
|
122
|
+
|
|
123
|
+
def test_fan_out_then_fan_in
|
|
124
|
+
TestPipeline.run(video_id: 1)
|
|
125
|
+
.then(
|
|
126
|
+
[NotificationPipeline, { with: {} }],
|
|
127
|
+
[AnalyticsPipeline, { with: {} }]
|
|
128
|
+
)
|
|
129
|
+
.then(ArchivePipeline, with: {})
|
|
130
|
+
|
|
131
|
+
all_records = GoodPipeline::PipelineRecord.all.to_a
|
|
132
|
+
run_all_to_completion(all_records)
|
|
133
|
+
|
|
134
|
+
assert all_records.all?(&:succeeded?), "All pipelines should have succeeded"
|
|
135
|
+
|
|
136
|
+
archive = all_records.find { |pipeline| pipeline.type == "ArchivePipeline" }
|
|
137
|
+
|
|
138
|
+
assert_equal 2, archive.upstream_pipelines.count
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# --- Deep chain failure propagation ---
|
|
142
|
+
|
|
143
|
+
def test_deep_chain_failure_propagates_skips
|
|
144
|
+
HaltTestPipeline.run
|
|
145
|
+
.then(NotificationPipeline, with: {})
|
|
146
|
+
.then(AnalyticsPipeline, with: {})
|
|
147
|
+
.then(ArchivePipeline, with: {})
|
|
148
|
+
|
|
149
|
+
all_records = GoodPipeline::PipelineRecord.all.to_a
|
|
150
|
+
run_all_to_completion(all_records)
|
|
151
|
+
|
|
152
|
+
notification = all_records.find { |pipeline| pipeline.type == "NotificationPipeline" }
|
|
153
|
+
analytics = all_records.find { |pipeline| pipeline.type == "AnalyticsPipeline" }
|
|
154
|
+
archive = all_records.find { |pipeline| pipeline.type == "ArchivePipeline" }
|
|
155
|
+
|
|
156
|
+
assert_equal "skipped", notification.status
|
|
157
|
+
assert_equal "skipped", analytics.status
|
|
158
|
+
assert_equal "skipped", archive.status
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# --- Callbacks on skipped ---
|
|
162
|
+
|
|
163
|
+
def test_skipped_pipeline_fires_on_complete_but_not_on_failure
|
|
164
|
+
callback_pipeline_class = Class.new(GoodPipeline::Pipeline) do
|
|
165
|
+
on_complete :handle_complete
|
|
166
|
+
|
|
167
|
+
define_method(:configure) { |**_kwargs| run :step, DownloadJob }
|
|
168
|
+
define_method(:handle_complete) {} # no-op
|
|
169
|
+
end
|
|
170
|
+
Object.const_set(:SkipCallbackPipeline, callback_pipeline_class) unless defined?(::SkipCallbackPipeline)
|
|
171
|
+
|
|
172
|
+
HaltTestPipeline.run
|
|
173
|
+
.then(SkipCallbackPipeline, with: {})
|
|
174
|
+
|
|
175
|
+
all_records = GoodPipeline::PipelineRecord.all.to_a
|
|
176
|
+
run_all_to_completion(all_records)
|
|
177
|
+
|
|
178
|
+
skipped_pipeline = all_records.find { |pipeline| pipeline.type == "SkipCallbackPipeline" }
|
|
179
|
+
|
|
180
|
+
assert_equal "skipped", skipped_pipeline.status
|
|
181
|
+
assert_not_nil skipped_pipeline.callbacks_dispatched_at
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestRetryScenarios < ActiveSupport::TestCase
|
|
6
|
+
def test_retry_then_succeed_keeps_enqueued_during_retries
|
|
7
|
+
pipeline = create_pipeline(on_failure_strategy: "continue")
|
|
8
|
+
pipeline.update_columns(status: "running")
|
|
9
|
+
step_a = build_step(pipeline, key: "a", job_class: "RetryableJob")
|
|
10
|
+
step_b = build_step(pipeline, key: "b", dependencies: [step_a])
|
|
11
|
+
|
|
12
|
+
tracker_key = SecureRandom.hex(8)
|
|
13
|
+
ActiveRecord::Base.connection.execute(
|
|
14
|
+
ActiveRecord::Base.sanitize_sql(
|
|
15
|
+
["INSERT INTO attempt_trackers (key, count) VALUES (?, 0)", tracker_key]
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
step_a.update_column(:params, { "tracker_key" => tracker_key })
|
|
19
|
+
|
|
20
|
+
GoodPipeline::Coordinator.try_enqueue_step(step_a.id)
|
|
21
|
+
|
|
22
|
+
saw_failed = false
|
|
23
|
+
wait_until(timeout: 15) do
|
|
24
|
+
perform_enqueued_jobs_inline
|
|
25
|
+
step_a.reload
|
|
26
|
+
saw_failed = true if step_a.coordination_status == "failed"
|
|
27
|
+
step_a.coordination_status == "succeeded"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
refute saw_failed, "coordination_status should never have been 'failed' during retries"
|
|
31
|
+
assert_equal "succeeded", step_a.reload.coordination_status
|
|
32
|
+
|
|
33
|
+
perform_enqueued_jobs_inline
|
|
34
|
+
wait_until(timeout: 5) do
|
|
35
|
+
perform_enqueued_jobs_inline
|
|
36
|
+
step_b.reload
|
|
37
|
+
step_b.coordination_status != "pending"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
refute_equal "pending", step_b.reload.coordination_status
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_retry_then_exhaust_transitions_to_failed
|
|
44
|
+
pipeline = create_pipeline(on_failure_strategy: "continue")
|
|
45
|
+
pipeline.update_columns(status: "running")
|
|
46
|
+
step = build_step(pipeline, key: "a", job_class: "AlwaysFailingJob")
|
|
47
|
+
|
|
48
|
+
GoodPipeline::Coordinator.try_enqueue_step(step.id)
|
|
49
|
+
|
|
50
|
+
wait_until(timeout: 15) do
|
|
51
|
+
begin
|
|
52
|
+
perform_enqueued_jobs_inline
|
|
53
|
+
rescue AlwaysFailingJob::AlwaysFailingError
|
|
54
|
+
# Expected — GoodJob re-raises after exhausting retries in inline/external mode
|
|
55
|
+
end
|
|
56
|
+
step.reload
|
|
57
|
+
step.coordination_status == "failed"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
step.reload
|
|
61
|
+
|
|
62
|
+
assert_equal "failed", step.coordination_status
|
|
63
|
+
assert_not_nil step.error_class
|
|
64
|
+
assert_not_nil step.error_message
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_discard_on_immediate_failure_halts_pipeline
|
|
68
|
+
pipeline = create_pipeline(on_failure_strategy: "halt")
|
|
69
|
+
pipeline.update_columns(status: "running")
|
|
70
|
+
step_a = build_step(pipeline, key: "a", job_class: "FailingJob")
|
|
71
|
+
step_b = build_step(pipeline, key: "b", dependencies: [step_a])
|
|
72
|
+
|
|
73
|
+
GoodPipeline::Coordinator.try_enqueue_step(step_a.id)
|
|
74
|
+
|
|
75
|
+
wait_until(timeout: 10) do
|
|
76
|
+
perform_enqueued_jobs_inline
|
|
77
|
+
pipeline.reload
|
|
78
|
+
pipeline.terminal?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
step_a.reload
|
|
82
|
+
|
|
83
|
+
assert_equal "failed", step_a.coordination_status
|
|
84
|
+
assert_equal "FailingJob::FailingError", step_a.error_class
|
|
85
|
+
|
|
86
|
+
assert_equal "skipped", step_b.reload.coordination_status
|
|
87
|
+
assert_predicate pipeline.reload, :halt_triggered?
|
|
88
|
+
assert_equal "halted", pipeline.status
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestStepFinishedIdempotency < ActiveSupport::TestCase
|
|
6
|
+
def test_complete_step_on_already_succeeded_step_is_noop
|
|
7
|
+
pipeline = create_pipeline
|
|
8
|
+
pipeline.update_columns(status: "running")
|
|
9
|
+
step = build_step(pipeline, key: "a")
|
|
10
|
+
step.update_columns(coordination_status: "succeeded")
|
|
11
|
+
|
|
12
|
+
GoodPipeline::Coordinator.complete_step(step.reload, succeeded: true)
|
|
13
|
+
|
|
14
|
+
assert_equal "succeeded", step.reload.coordination_status
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_complete_step_on_already_failed_step_is_noop
|
|
18
|
+
pipeline = create_pipeline
|
|
19
|
+
pipeline.update_columns(status: "running")
|
|
20
|
+
step = build_step(pipeline, key: "a")
|
|
21
|
+
step.update_columns(coordination_status: "failed")
|
|
22
|
+
|
|
23
|
+
GoodPipeline::Coordinator.complete_step(step.reload, succeeded: false)
|
|
24
|
+
|
|
25
|
+
assert_equal "failed", step.reload.coordination_status
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_complete_step_on_already_skipped_step_is_noop
|
|
29
|
+
pipeline = create_pipeline
|
|
30
|
+
pipeline.update_columns(status: "running")
|
|
31
|
+
step = build_step(pipeline, key: "a")
|
|
32
|
+
step.update_columns(coordination_status: "skipped")
|
|
33
|
+
|
|
34
|
+
GoodPipeline::Coordinator.complete_step(step.reload, succeeded: true)
|
|
35
|
+
|
|
36
|
+
assert_equal "skipped", step.reload.coordination_status
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ENV["RAILS_ENV"] = "test"
|
|
4
|
+
require_relative "../config/environment"
|
|
5
|
+
require "rails/test_help"
|
|
6
|
+
require "minitest/autorun"
|
|
7
|
+
|
|
8
|
+
ActiveJob::Base.logger = Logger.new(nil)
|
|
9
|
+
|
|
10
|
+
# Create attempt_trackers table for retry tests
|
|
11
|
+
ActiveRecord::Base.connection.create_table :attempt_trackers, if_not_exists: true do |t|
|
|
12
|
+
t.string :key, null: false
|
|
13
|
+
t.integer :count, default: 0, null: false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module ActiveSupport
|
|
17
|
+
class TestCase
|
|
18
|
+
self.use_transactional_tests = false
|
|
19
|
+
|
|
20
|
+
teardown do
|
|
21
|
+
ActiveRecord::Base.connection.truncate_tables(*ActiveRecord::Base.connection.tables)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def rails_promise(&block)
|
|
27
|
+
Concurrent::Promises.future do
|
|
28
|
+
Rails.application.executor.wrap(&block)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def perform_enqueued_jobs_inline
|
|
33
|
+
GoodJob.perform_inline
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def wait_until(timeout: 10, interval: 0.1)
|
|
37
|
+
deadline = Time.current + timeout
|
|
38
|
+
loop do
|
|
39
|
+
return if yield
|
|
40
|
+
|
|
41
|
+
raise "Timeout waiting for condition" if Time.current > deadline
|
|
42
|
+
|
|
43
|
+
sleep interval
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_pipeline(**attributes)
|
|
48
|
+
GoodPipeline::PipelineRecord.create!(
|
|
49
|
+
{ type: "TestPipeline" }.merge(attributes)
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def create_step(pipeline, key: "step_a", job_class: "DownloadJob", **attributes)
|
|
54
|
+
GoodPipeline::StepRecord.create!(
|
|
55
|
+
{
|
|
56
|
+
pipeline: pipeline,
|
|
57
|
+
key: key,
|
|
58
|
+
job_class: job_class
|
|
59
|
+
}.merge(attributes)
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_step(pipeline, key:, dependencies: [], on_failure_strategy: nil, **attributes)
|
|
64
|
+
step = create_step(pipeline, key: key, on_failure_strategy: on_failure_strategy, **attributes)
|
|
65
|
+
dependencies.each do |dependency_step|
|
|
66
|
+
GoodPipeline::DependencyRecord.create!(pipeline: pipeline, step: step, depends_on_step: dependency_step)
|
|
67
|
+
end
|
|
68
|
+
step
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:17.5
|
|
4
|
+
restart: unless-stopped
|
|
5
|
+
command: postgres -c "shared_preload_libraries=pg_stat_statements" -c "pg_stat_statements.track=all" -c "pg_stat_statements.max=10000" -c "track_activity_query_size=2048"
|
|
6
|
+
ports:
|
|
7
|
+
- "5432:5432"
|
|
8
|
+
volumes:
|
|
9
|
+
- postgres-data:/var/lib/postgresql/data
|
|
10
|
+
environment:
|
|
11
|
+
- POSTGRES_DB=postgres
|
|
12
|
+
- POSTGRES_USER=postgres
|
|
13
|
+
- POSTGRES_PASSWORD=postgres
|
|
14
|
+
|
|
15
|
+
volumes:
|
|
16
|
+
postgres-data:
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { defineConfig } from 'vitepress'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
title: 'GoodPipeline',
|
|
5
|
+
description: 'DAG-based job pipeline orchestration for Rails, built on GoodJob.',
|
|
6
|
+
base: '/good_pipeline/',
|
|
7
|
+
|
|
8
|
+
themeConfig: {
|
|
9
|
+
nav: [
|
|
10
|
+
{ text: 'Home', link: '/' },
|
|
11
|
+
{ text: 'Docs', link: '/introduction' },
|
|
12
|
+
],
|
|
13
|
+
|
|
14
|
+
sidebar: [
|
|
15
|
+
{
|
|
16
|
+
text: 'Getting Started',
|
|
17
|
+
items: [
|
|
18
|
+
{ text: 'Introduction', link: '/introduction' },
|
|
19
|
+
{ text: 'Installation & Setup', link: '/getting-started' },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
text: 'Core Guides',
|
|
24
|
+
items: [
|
|
25
|
+
{ text: 'Defining Pipelines', link: '/defining-pipelines' },
|
|
26
|
+
{ text: 'DAG Validation', link: '/dag-validation' },
|
|
27
|
+
{ text: 'Failure Strategies', link: '/failure-strategies' },
|
|
28
|
+
{ text: 'Pipeline Chaining', link: '/pipeline-chaining' },
|
|
29
|
+
{ text: 'Lifecycle Callbacks', link: '/callbacks' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
text: 'Operations',
|
|
34
|
+
items: [
|
|
35
|
+
{ text: 'Monitoring & Introspection', link: '/monitoring' },
|
|
36
|
+
{ text: 'Web Dashboard', link: '/dashboard' },
|
|
37
|
+
{ text: 'Cleanup', link: '/cleanup' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
text: 'Reference',
|
|
42
|
+
items: [
|
|
43
|
+
{ text: 'Architecture', link: '/architecture' },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
|
|
48
|
+
socialLinks: [
|
|
49
|
+
{ icon: 'github', link: 'https://github.com/milkstrawai/good_pipeline' },
|
|
50
|
+
],
|
|
51
|
+
|
|
52
|
+
editLink: {
|
|
53
|
+
pattern: 'https://github.com/milkstrawai/good_pipeline/edit/main/docs/:path',
|
|
54
|
+
text: 'Edit this page on GitHub',
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
search: {
|
|
58
|
+
provider: 'local',
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
footer: {
|
|
62
|
+
message: 'Released under the MIT License.',
|
|
63
|
+
copyright: 'Copyright 2026 MilkStraw AI',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--vp-c-brand-1: #d97706;
|
|
3
|
+
--vp-c-brand-2: #b45309;
|
|
4
|
+
--vp-c-brand-3: #d97706;
|
|
5
|
+
--vp-c-brand-soft: rgba(217, 119, 6, 0.14);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.dark {
|
|
9
|
+
--vp-c-brand-1: #f59e0b;
|
|
10
|
+
--vp-c-brand-2: #fbbf24;
|
|
11
|
+
--vp-c-brand-3: #f59e0b;
|
|
12
|
+
--vp-c-brand-soft: rgba(245, 158, 11, 0.14);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.VPHero .name {
|
|
16
|
+
font-size: 2.5rem !important;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.VPHero .text {
|
|
20
|
+
font-size: 1.5rem !important;
|
|
21
|
+
}
|