good_pipeline 0.1.0

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