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,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