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,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestCoordinator < ActiveSupport::TestCase
6
+ # --- Helper to create a GoodJob::Job record for failure metadata ---
7
+
8
+ def create_good_job_for_step(step, error: "RuntimeError: something broke", executions_count: 3)
9
+ good_job = GoodJob::Job.create!(
10
+ id: SecureRandom.uuid,
11
+ active_job_id: SecureRandom.uuid,
12
+ job_class: step.job_class,
13
+ error: error,
14
+ executions_count: executions_count,
15
+ finished_at: Time.current
16
+ )
17
+ step.update_column(:good_job_id, good_job.id)
18
+ good_job
19
+ end
20
+
21
+ # --- complete_step: idempotency ---
22
+
23
+ def test_complete_step_idempotent_on_terminal_step
24
+ pipeline = create_pipeline(on_failure_strategy: "halt")
25
+ step = build_step(pipeline, key: "a")
26
+ step.update_columns(coordination_status: "succeeded")
27
+ step.reload
28
+
29
+ GoodPipeline::Coordinator.complete_step(step, succeeded: true)
30
+
31
+ assert_equal "succeeded", step.reload.coordination_status
32
+ end
33
+
34
+ # --- complete_step: succeeded ---
35
+
36
+ def test_complete_step_succeeded_transitions
37
+ pipeline = create_pipeline(on_failure_strategy: "halt")
38
+ pipeline.update_columns(status: "running")
39
+ step = build_step(pipeline, key: "a")
40
+ step.update_columns(coordination_status: "enqueued")
41
+ step.reload
42
+
43
+ GoodPipeline::Coordinator.complete_step(step, succeeded: true)
44
+
45
+ assert_equal "succeeded", step.reload.coordination_status
46
+ end
47
+
48
+ # --- complete_step: failed ---
49
+
50
+ def test_complete_step_failed_transitions_and_sets_metadata
51
+ pipeline = create_pipeline(on_failure_strategy: "halt")
52
+ pipeline.update_columns(status: "running")
53
+ step = build_step(pipeline, key: "a")
54
+ step.update_columns(coordination_status: "enqueued")
55
+ create_good_job_for_step(step, error: "RuntimeError: something broke", executions_count: 3)
56
+ step.reload
57
+
58
+ GoodPipeline::Coordinator.complete_step(step, succeeded: false)
59
+
60
+ step.reload
61
+
62
+ assert_equal "failed", step.coordination_status
63
+ assert_equal "RuntimeError", step.error_class
64
+ assert_equal "something broke", step.error_message
65
+ assert_equal 3, step.attempts
66
+ end
67
+
68
+ # --- recompute_pipeline_status: derivation ---
69
+
70
+ def test_recompute_returns_early_when_steps_not_all_terminal
71
+ pipeline = create_pipeline(on_failure_strategy: "halt")
72
+ pipeline.update_columns(status: "running")
73
+ build_step(pipeline, key: "a")
74
+ build_step(pipeline, key: "b")
75
+
76
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline)
77
+
78
+ assert_equal "running", pipeline.reload.status
79
+ end
80
+
81
+ def test_recompute_derives_succeeded_when_all_steps_succeeded
82
+ pipeline = create_pipeline(on_failure_strategy: "halt")
83
+ pipeline.update_columns(status: "running")
84
+ step_a = build_step(pipeline, key: "a")
85
+ step_b = build_step(pipeline, key: "b")
86
+ step_a.update_columns(coordination_status: "succeeded")
87
+ step_b.update_columns(coordination_status: "succeeded")
88
+
89
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline.reload)
90
+
91
+ assert_equal "succeeded", pipeline.reload.status
92
+ end
93
+
94
+ def test_recompute_derives_halted_when_halt_triggered
95
+ pipeline = create_pipeline(on_failure_strategy: "halt")
96
+ pipeline.update_columns(status: "running", halt_triggered: true)
97
+ step_a = build_step(pipeline, key: "a")
98
+ step_b = build_step(pipeline, key: "b")
99
+ step_a.update_columns(coordination_status: "failed")
100
+ step_b.update_columns(coordination_status: "skipped")
101
+
102
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline.reload)
103
+
104
+ assert_equal "halted", pipeline.reload.status
105
+ end
106
+
107
+ def test_recompute_derives_failed_when_no_halt_triggered
108
+ pipeline = create_pipeline(on_failure_strategy: "continue")
109
+ pipeline.update_columns(status: "running")
110
+ step_a = build_step(pipeline, key: "a")
111
+ step_b = build_step(pipeline, key: "b")
112
+ step_a.update_columns(coordination_status: "failed")
113
+ step_b.update_columns(coordination_status: "succeeded")
114
+
115
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline.reload)
116
+
117
+ assert_equal "failed", pipeline.reload.status
118
+ end
119
+
120
+ def test_recompute_is_idempotent_on_terminal_pipeline
121
+ pipeline = create_pipeline(on_failure_strategy: "halt")
122
+ pipeline.update_columns(status: "succeeded", callbacks_dispatched_at: Time.current)
123
+ build_step(pipeline, key: "a").update_columns(coordination_status: "succeeded")
124
+
125
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline.reload)
126
+
127
+ assert_equal "succeeded", pipeline.reload.status
128
+ end
129
+
130
+ # --- dispatch_callbacks_once ---
131
+
132
+ def test_dispatch_callbacks_sets_callbacks_dispatched_at
133
+ pipeline = create_pipeline(on_failure_strategy: "halt")
134
+ pipeline.update_columns(status: "running")
135
+ build_step(pipeline, key: "a").update_columns(coordination_status: "succeeded")
136
+
137
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline.reload)
138
+
139
+ pipeline.reload
140
+
141
+ assert_equal "succeeded", pipeline.status
142
+ refute_nil pipeline.callbacks_dispatched_at
143
+ end
144
+
145
+ def test_dispatch_callbacks_exactly_once
146
+ pipeline = create_pipeline(on_failure_strategy: "halt")
147
+ pipeline.update_columns(status: "running")
148
+ build_step(pipeline, key: "a").update_columns(coordination_status: "succeeded")
149
+
150
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline.reload)
151
+ first_dispatched_at = pipeline.reload.callbacks_dispatched_at
152
+
153
+ # Call again — should not change anything
154
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline.reload)
155
+
156
+ assert_equal first_dispatched_at, pipeline.reload.callbacks_dispatched_at
157
+ end
158
+
159
+ # --- Halt propagation ---
160
+
161
+ def test_halt_skips_all_pending_steps
162
+ pipeline = create_pipeline(on_failure_strategy: "halt")
163
+ pipeline.update_columns(status: "running")
164
+ step_a = build_step(pipeline, key: "a")
165
+ step_b = build_step(pipeline, key: "b", dependencies: [step_a])
166
+ step_c = build_step(pipeline, key: "c", dependencies: [step_a])
167
+ step_a.update_columns(coordination_status: "enqueued")
168
+
169
+ GoodPipeline::Coordinator.complete_step(step_a.reload, succeeded: false)
170
+
171
+ assert_equal "failed", step_a.reload.coordination_status
172
+ assert_equal "skipped", step_b.reload.coordination_status
173
+ assert_equal "skipped", step_c.reload.coordination_status
174
+ assert_predicate pipeline.reload, :halt_triggered?
175
+ assert_equal "halted", pipeline.status
176
+ end
177
+
178
+ def test_halt_with_step_ignore_exempts_dependents
179
+ pipeline = create_pipeline(on_failure_strategy: "halt")
180
+ pipeline.update_columns(status: "running")
181
+ step_a = build_step(pipeline, key: "a", on_failure_strategy: "ignore")
182
+ step_b = build_step(pipeline, key: "b", dependencies: [step_a])
183
+ step_c = build_step(pipeline, key: "c")
184
+ step_a.update_columns(coordination_status: "enqueued")
185
+
186
+ GoodPipeline::Coordinator.complete_step(step_a.reload, succeeded: false)
187
+
188
+ assert_equal "failed", step_a.reload.coordination_status
189
+ # step_b is a direct dependent of step_a (which has failure_strategy: :ignore) — should NOT be skipped
190
+ refute_equal "skipped", step_b.reload.coordination_status
191
+ # step_c is unrelated — should be skipped under :halt
192
+ assert_equal "skipped", step_c.reload.coordination_status
193
+ assert_predicate pipeline.reload, :halt_triggered?
194
+ end
195
+
196
+ # --- Continue strategy ---
197
+
198
+ def test_continue_skips_only_unsatisfied_descendants
199
+ pipeline = create_pipeline(on_failure_strategy: "continue")
200
+ pipeline.update_columns(status: "running")
201
+ step_a = build_step(pipeline, key: "a")
202
+ step_b = build_step(pipeline, key: "b")
203
+ step_c = build_step(pipeline, key: "c", dependencies: [step_a])
204
+ step_a.update_columns(coordination_status: "enqueued")
205
+ step_b.update_columns(coordination_status: "succeeded")
206
+
207
+ GoodPipeline::Coordinator.complete_step(step_a.reload, succeeded: false)
208
+
209
+ assert_equal "failed", step_a.reload.coordination_status
210
+ assert_equal "skipped", step_c.reload.coordination_status
211
+ assert_equal "succeeded", step_b.reload.coordination_status
212
+ refute_predicate pipeline.reload, :halt_triggered?
213
+ assert_equal "failed", pipeline.reload.status
214
+ end
215
+
216
+ # --- Ignore strategy ---
217
+
218
+ def test_ignore_nothing_skipped
219
+ pipeline = create_pipeline(on_failure_strategy: "ignore")
220
+ pipeline.update_columns(status: "running")
221
+ step_a = build_step(pipeline, key: "a")
222
+ build_step(pipeline, key: "b", dependencies: [step_a])
223
+ step_a.update_columns(coordination_status: "enqueued")
224
+
225
+ GoodPipeline::Coordinator.complete_step(step_a.reload, succeeded: false)
226
+
227
+ assert_equal "failed", step_a.reload.coordination_status
228
+ refute_predicate pipeline.reload, :halt_triggered?
229
+ end
230
+
231
+ # --- Single-step pipeline reaches terminal ---
232
+
233
+ def test_single_step_pipeline_succeeds
234
+ pipeline = create_pipeline(on_failure_strategy: "halt")
235
+ pipeline.update_columns(status: "running")
236
+ step = build_step(pipeline, key: "a")
237
+ step.update_columns(coordination_status: "enqueued")
238
+
239
+ GoodPipeline::Coordinator.complete_step(step.reload, succeeded: true)
240
+
241
+ assert_equal "succeeded", pipeline.reload.status
242
+ end
243
+
244
+ # --- try_enqueue_step ---
245
+
246
+ def test_try_enqueue_bails_on_non_pending_step
247
+ pipeline = create_pipeline(on_failure_strategy: "halt")
248
+ step = build_step(pipeline, key: "a")
249
+ step.update_columns(coordination_status: "enqueued")
250
+
251
+ GoodPipeline::Coordinator.try_enqueue_step(step.id)
252
+
253
+ assert_equal "enqueued", step.reload.coordination_status
254
+ end
255
+
256
+ def test_try_enqueue_bails_when_good_job_id_present
257
+ pipeline = create_pipeline(on_failure_strategy: "halt")
258
+ step = build_step(pipeline, key: "a")
259
+ step.update_columns(good_job_id: SecureRandom.uuid)
260
+
261
+ GoodPipeline::Coordinator.try_enqueue_step(step.id)
262
+
263
+ assert_equal "pending", step.reload.coordination_status
264
+ end
265
+
266
+ def test_try_enqueue_bails_when_upstreams_not_satisfied
267
+ pipeline = create_pipeline(on_failure_strategy: "halt")
268
+ step_a = build_step(pipeline, key: "a")
269
+ step_b = build_step(pipeline, key: "b", dependencies: [step_a])
270
+
271
+ GoodPipeline::Coordinator.try_enqueue_step(step_b.id)
272
+
273
+ assert_equal "pending", step_b.reload.coordination_status
274
+ end
275
+
276
+ def test_try_enqueue_skips_permanently_unsatisfied_step
277
+ pipeline = create_pipeline(on_failure_strategy: "continue")
278
+ step_a = build_step(pipeline, key: "a")
279
+ step_b = build_step(pipeline, key: "b", dependencies: [step_a])
280
+ step_a.update_columns(coordination_status: "failed")
281
+
282
+ GoodPipeline::Coordinator.try_enqueue_step(step_b.id)
283
+
284
+ assert_equal "skipped", step_b.reload.coordination_status
285
+ end
286
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestDependencyRecord < ActiveSupport::TestCase
6
+ # --- Diamond DAG graph navigation ---
7
+ #
8
+ # A
9
+ # / \
10
+ # B C
11
+ # \ /
12
+ # D
13
+
14
+ def test_diamond_dag_navigation
15
+ pipeline = create_pipeline
16
+ step_a = create_step(pipeline, key: "a")
17
+ step_b = create_step(pipeline, key: "b")
18
+ step_c = create_step(pipeline, key: "c")
19
+ step_d = create_step(pipeline, key: "d")
20
+
21
+ # B depends on A, C depends on A
22
+ GoodPipeline::DependencyRecord.create!(pipeline: pipeline, step: step_b, depends_on_step: step_a)
23
+ GoodPipeline::DependencyRecord.create!(pipeline: pipeline, step: step_c, depends_on_step: step_a)
24
+ # D depends on B and C
25
+ GoodPipeline::DependencyRecord.create!(pipeline: pipeline, step: step_d, depends_on_step: step_b)
26
+ GoodPipeline::DependencyRecord.create!(pipeline: pipeline, step: step_d, depends_on_step: step_c)
27
+
28
+ # A has no upstream steps
29
+ assert_empty step_a.upstream_steps
30
+
31
+ # A's downstream: B and C
32
+ assert_equal %w[b c], step_a.downstream_steps.map(&:key).sort
33
+
34
+ # B's upstream: A
35
+ assert_equal ["a"], step_b.upstream_steps.map(&:key)
36
+
37
+ # B's downstream: D
38
+ assert_equal ["d"], step_b.downstream_steps.map(&:key)
39
+
40
+ # D's upstream: B and C
41
+ assert_equal %w[b c], step_d.upstream_steps.map(&:key).sort
42
+
43
+ # D has no downstream
44
+ assert_empty step_d.downstream_steps
45
+ end
46
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestFailureMetadata < ActiveSupport::TestCase
6
+ def test_extract_returns_empty_result_when_no_good_job_record
7
+ pipeline = create_pipeline
8
+ step = build_step(pipeline, key: "a")
9
+
10
+ result = GoodPipeline::FailureMetadata.extract(step)
11
+
12
+ assert_nil result.error_class
13
+ assert_nil result.error_message
14
+ assert_equal 0, result.attempts
15
+ end
16
+
17
+ def test_extract_parses_error_from_good_job_record
18
+ pipeline = create_pipeline
19
+ step = build_step(pipeline, key: "a")
20
+
21
+ good_job = GoodJob::Job.create!(
22
+ id: SecureRandom.uuid,
23
+ active_job_id: SecureRandom.uuid,
24
+ job_class: "DownloadJob",
25
+ error: "RuntimeError: something went wrong",
26
+ executions_count: 3,
27
+ finished_at: Time.current
28
+ )
29
+ step.update_column(:good_job_id, good_job.id)
30
+
31
+ result = GoodPipeline::FailureMetadata.extract(step.reload)
32
+
33
+ assert_equal "RuntimeError", result.error_class
34
+ assert_equal "something went wrong", result.error_message
35
+ assert_equal 3, result.attempts
36
+ end
37
+
38
+ def test_extract_handles_error_message_with_colon
39
+ pipeline = create_pipeline
40
+ step = build_step(pipeline, key: "a")
41
+
42
+ good_job = GoodJob::Job.create!(
43
+ id: SecureRandom.uuid,
44
+ active_job_id: SecureRandom.uuid,
45
+ job_class: "DownloadJob",
46
+ error: "Net::HTTPError: 404: Not Found",
47
+ executions_count: 1,
48
+ finished_at: Time.current
49
+ )
50
+ step.update_column(:good_job_id, good_job.id)
51
+
52
+ result = GoodPipeline::FailureMetadata.extract(step.reload)
53
+
54
+ assert_equal "Net::HTTPError", result.error_class
55
+ assert_equal "404: Not Found", result.error_message
56
+ end
57
+
58
+ def test_extract_handles_nil_error
59
+ pipeline = create_pipeline
60
+ step = build_step(pipeline, key: "a")
61
+
62
+ good_job = GoodJob::Job.create!(
63
+ id: SecureRandom.uuid,
64
+ active_job_id: SecureRandom.uuid,
65
+ job_class: "DownloadJob",
66
+ error: nil,
67
+ executions_count: 1,
68
+ finished_at: Time.current
69
+ )
70
+ step.update_column(:good_job_id, good_job.id)
71
+
72
+ result = GoodPipeline::FailureMetadata.extract(step.reload)
73
+
74
+ assert_nil result.error_class
75
+ assert_nil result.error_message
76
+ end
77
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestIntrospection < ActiveSupport::TestCase
6
+ def test_duration_returns_seconds_from_good_job
7
+ pipeline = create_pipeline
8
+ step = create_step(pipeline, key: "a")
9
+
10
+ performed_at = 10.seconds.ago
11
+ finished_at = Time.current
12
+ good_job = GoodJob::Job.create!(
13
+ id: SecureRandom.uuid,
14
+ active_job_id: SecureRandom.uuid,
15
+ job_class: "DownloadJob",
16
+ performed_at: performed_at,
17
+ finished_at: finished_at
18
+ )
19
+ step.update_column(:good_job_id, good_job.id)
20
+
21
+ assert_in_delta 10.0, step.duration, 0.5
22
+ end
23
+
24
+ def test_duration_returns_nil_when_no_good_job_id
25
+ pipeline = create_pipeline
26
+ step = create_step(pipeline, key: "a")
27
+
28
+ assert_nil step.duration
29
+ end
30
+
31
+ def test_duration_returns_nil_when_good_job_not_performed
32
+ pipeline = create_pipeline
33
+ step = create_step(pipeline, key: "a")
34
+
35
+ good_job = GoodJob::Job.create!(
36
+ id: SecureRandom.uuid,
37
+ active_job_id: SecureRandom.uuid,
38
+ job_class: "DownloadJob",
39
+ performed_at: nil,
40
+ finished_at: nil
41
+ )
42
+ step.update_column(:good_job_id, good_job.id)
43
+
44
+ assert_nil step.duration
45
+ end
46
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestPipelineCallbackJob < ActiveSupport::TestCase
6
+ def setup
7
+ super
8
+ @callback_log = []
9
+ end
10
+
11
+ def build_pipeline_class(on_complete: nil, on_success: nil, on_failure: nil)
12
+ log = @callback_log
13
+ Class.new(GoodPipeline::Pipeline) do
14
+ self.on_complete(on_complete) if on_complete
15
+ self.on_success(on_success) if on_success
16
+ self.on_failure(on_failure) if on_failure
17
+
18
+ define_method(:notify_complete) { log << :on_complete }
19
+ define_method(:notify_success) { log << :on_success }
20
+ define_method(:notify_failure) { log << :on_failure }
21
+
22
+ def configure(**) = run(:a, Class.new)
23
+ end
24
+ end
25
+
26
+ def test_calls_on_complete_and_on_success_for_succeeded
27
+ klass = build_pipeline_class(on_complete: :notify_complete, on_success: :notify_success)
28
+ # We need a named class for constantize to work
29
+ self.class.const_set(:SucceededPipeline, klass) unless self.class.const_defined?(:SucceededPipeline)
30
+
31
+ pipeline = create_pipeline(type: self.class::SucceededPipeline.name)
32
+ job = GoodPipeline::PipelineCallbackJob.new
33
+ job.perform(pipeline.id, "succeeded")
34
+
35
+ assert_includes @callback_log, :on_complete
36
+ assert_includes @callback_log, :on_success
37
+ refute_includes @callback_log, :on_failure
38
+ end
39
+
40
+ def test_calls_on_complete_and_on_failure_for_failed
41
+ klass = build_pipeline_class(on_complete: :notify_complete, on_failure: :notify_failure)
42
+ self.class.const_set(:FailedPipeline, klass) unless self.class.const_defined?(:FailedPipeline)
43
+
44
+ pipeline = create_pipeline(type: self.class::FailedPipeline.name)
45
+ job = GoodPipeline::PipelineCallbackJob.new
46
+ job.perform(pipeline.id, "failed")
47
+
48
+ assert_includes @callback_log, :on_complete
49
+ assert_includes @callback_log, :on_failure
50
+ refute_includes @callback_log, :on_success
51
+ end
52
+
53
+ def test_calls_on_complete_and_on_failure_for_halted
54
+ klass = build_pipeline_class(on_complete: :notify_complete, on_failure: :notify_failure)
55
+ self.class.const_set(:HaltedPipeline, klass) unless self.class.const_defined?(:HaltedPipeline)
56
+
57
+ pipeline = create_pipeline(type: self.class::HaltedPipeline.name)
58
+ job = GoodPipeline::PipelineCallbackJob.new
59
+ job.perform(pipeline.id, "halted")
60
+
61
+ assert_includes @callback_log, :on_complete
62
+ assert_includes @callback_log, :on_failure
63
+ end
64
+
65
+ def test_handles_nil_callbacks_gracefully
66
+ klass = build_pipeline_class
67
+ self.class.const_set(:NilCallbackPipeline, klass) unless self.class.const_defined?(:NilCallbackPipeline)
68
+
69
+ pipeline = create_pipeline(type: self.class::NilCallbackPipeline.name)
70
+ job = GoodPipeline::PipelineCallbackJob.new
71
+ job.perform(pipeline.id, "succeeded")
72
+
73
+ assert_empty @callback_log
74
+ end
75
+
76
+ def test_rejects_invalid_terminal_status
77
+ klass = build_pipeline_class
78
+ self.class.const_set(:InvalidStatusPipeline, klass) unless self.class.const_defined?(:InvalidStatusPipeline)
79
+
80
+ pipeline = create_pipeline(type: self.class::InvalidStatusPipeline.name)
81
+ job = GoodPipeline::PipelineCallbackJob.new
82
+
83
+ assert_raises(ArgumentError) { job.perform(pipeline.id, "invalid") }
84
+ assert_raises(ArgumentError) { job.perform(pipeline.id, "pending") }
85
+ end
86
+
87
+ def test_error_in_callback_is_raised
88
+ log = @callback_log
89
+ klass = Class.new(GoodPipeline::Pipeline) do
90
+ on_complete(:exploding_callback)
91
+
92
+ define_method(:exploding_callback) do
93
+ log << :exploded
94
+ raise StandardError, "callback exploded"
95
+ end
96
+
97
+ def configure(**) = run(:a, Class.new)
98
+ end
99
+ self.class.const_set(:ExplodingPipeline, klass) unless self.class.const_defined?(:ExplodingPipeline)
100
+
101
+ pipeline = create_pipeline(type: self.class::ExplodingPipeline.name)
102
+ job = GoodPipeline::PipelineCallbackJob.new
103
+
104
+ error = assert_raises(StandardError) { job.perform(pipeline.id, "succeeded") }
105
+ assert_equal "callback exploded", error.message
106
+ assert_includes @callback_log, :exploded
107
+ end
108
+
109
+ def test_error_in_first_callback_still_runs_second
110
+ log = @callback_log
111
+ klass = Class.new(GoodPipeline::Pipeline) do
112
+ on_complete(:exploding_complete)
113
+ on_success(:record_success)
114
+
115
+ define_method(:exploding_complete) do
116
+ log << :complete_exploded
117
+ raise StandardError, "complete exploded"
118
+ end
119
+ define_method(:record_success) { log << :success_recorded }
120
+
121
+ def configure(**) = run(:a, Class.new)
122
+ end
123
+ self.class.const_set(:BothCallbacksPipeline, klass) unless self.class.const_defined?(:BothCallbacksPipeline)
124
+
125
+ pipeline = create_pipeline(type: self.class::BothCallbacksPipeline.name)
126
+ job = GoodPipeline::PipelineCallbackJob.new
127
+
128
+ assert_raises(StandardError) { job.perform(pipeline.id, "succeeded") }
129
+ assert_includes @callback_log, :complete_exploded
130
+ assert_includes @callback_log, :success_recorded
131
+ end
132
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestPipelineReconciliationJob < ActiveSupport::TestCase
6
+ def test_recomputes_pipeline_status_for_succeeded_pipeline
7
+ pipeline = create_pipeline(on_failure_strategy: "halt")
8
+ pipeline.update_columns(status: "running")
9
+ build_step(pipeline, key: "a").update_columns(coordination_status: "succeeded")
10
+
11
+ batch = GoodJob::Batch.new
12
+ batch.properties = { pipeline_id: pipeline.id }
13
+ batch.save
14
+
15
+ GoodPipeline::PipelineReconciliationJob.new.perform(batch, {})
16
+
17
+ assert_equal "succeeded", pipeline.reload.status
18
+ end
19
+
20
+ def test_does_not_transition_when_steps_still_pending
21
+ pipeline = create_pipeline(on_failure_strategy: "halt")
22
+ pipeline.update_columns(status: "running")
23
+ build_step(pipeline, key: "a")
24
+
25
+ batch = GoodJob::Batch.new
26
+ batch.properties = { pipeline_id: pipeline.id }
27
+ batch.save
28
+
29
+ GoodPipeline::PipelineReconciliationJob.new.perform(batch, {})
30
+
31
+ assert_equal "running", pipeline.reload.status
32
+ end
33
+ end