good_pipeline 0.4.0 → 0.6.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/Appraisals +25 -0
  3. data/CHANGELOG.md +58 -0
  4. data/README.md +67 -11
  5. data/app/controllers/good_pipeline/frontends_controller.rb +18 -1
  6. data/app/controllers/good_pipeline/pipelines_controller.rb +254 -36
  7. data/app/controllers/good_pipeline/themes_controller.rb +15 -0
  8. data/app/frontend/good_pipeline/dashboard.css +407 -0
  9. data/app/frontend/good_pipeline/dashboard.js +542 -0
  10. data/app/helpers/good_pipeline/mermaid_diagram_builder.rb +225 -66
  11. data/app/helpers/good_pipeline/pipelines_helper.rb +89 -5
  12. data/app/jobs/good_pipeline/pipeline_callback_job.rb +2 -2
  13. data/app/jobs/good_pipeline/pipeline_reconciliation_job.rb +1 -2
  14. data/app/jobs/good_pipeline/step_finished_job.rb +6 -2
  15. data/app/models/good_pipeline/pipeline_record.rb +7 -4
  16. data/app/models/good_pipeline/step_record.rb +6 -3
  17. data/app/views/good_pipeline/pipelines/_actions.html.erb +28 -0
  18. data/app/views/good_pipeline/pipelines/_chain_links.html.erb +17 -29
  19. data/app/views/good_pipeline/pipelines/_diagram.html.erb +101 -0
  20. data/app/views/good_pipeline/pipelines/_filter_bar.html.erb +51 -0
  21. data/app/views/good_pipeline/pipelines/_kpi_strip.html.erb +74 -0
  22. data/app/views/good_pipeline/pipelines/_pager.html.erb +31 -0
  23. data/app/views/good_pipeline/pipelines/_row.html.erb +103 -0
  24. data/app/views/good_pipeline/pipelines/_row_detail.html.erb +93 -0
  25. data/app/views/good_pipeline/pipelines/_sidebar.html.erb +115 -0
  26. data/app/views/good_pipeline/pipelines/_steps_table.html.erb +46 -33
  27. data/app/views/good_pipeline/pipelines/_table.html.erb +33 -0
  28. data/app/views/good_pipeline/pipelines/_timeline.html.erb +55 -0
  29. data/app/views/good_pipeline/pipelines/_topbar.html.erb +54 -0
  30. data/app/views/good_pipeline/pipelines/definitions.html.erb +88 -55
  31. data/app/views/good_pipeline/pipelines/index.html.erb +31 -42
  32. data/app/views/good_pipeline/pipelines/show.html.erb +54 -47
  33. data/app/views/layouts/good_pipeline/application.html.erb +24 -153
  34. data/config/routes.rb +4 -0
  35. data/demo/config/environments/development.rb +3 -0
  36. data/demo/config/environments/test.rb +2 -1
  37. data/demo/db/migrate/20260319205325_create_good_jobs.rb +1 -1
  38. data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +1 -1
  39. data/demo/db/migrate/20260810000000_add_good_pipeline_dashboard_indexes.rb +20 -0
  40. data/demo/db/seeds.rb +325 -11
  41. data/demo/docs/screenshots/definitions.png +0 -0
  42. data/demo/docs/screenshots/show.png +0 -0
  43. data/demo/test/good_pipeline/test_bulk_enqueue.rb +54 -1
  44. data/demo/test/good_pipeline/test_cancellation.rb +312 -0
  45. data/demo/test/good_pipeline/test_cleanup.rb +31 -1
  46. data/demo/test/good_pipeline/test_coordinator.rb +39 -0
  47. data/demo/test/good_pipeline/test_dashboard.rb +667 -0
  48. data/demo/test/good_pipeline/test_dashboard_rerun.rb +244 -0
  49. data/demo/test/good_pipeline/test_pipeline_callback_job.rb +11 -0
  50. data/demo/test/good_pipeline/test_pipeline_record.rb +30 -0
  51. data/demo/test/good_pipeline/test_runner.rb +40 -1
  52. data/demo/test/good_pipeline/test_step_record.rb +37 -0
  53. data/demo/test/integration/test_barrier_execution.rb +502 -0
  54. data/demo/test/integration/test_chain_locking.rb +54 -0
  55. data/demo/test/integration/test_pipeline_chaining.rb +96 -0
  56. data/demo/test/test_helper.rb +20 -0
  57. data/docs/architecture.md +45 -29
  58. data/docs/branching.md +4 -0
  59. data/docs/callbacks.md +9 -7
  60. data/docs/cleanup.md +2 -2
  61. data/docs/dag-validation.md +16 -0
  62. data/docs/dashboard.md +84 -22
  63. data/docs/defining-pipelines.md +34 -1
  64. data/docs/failure-strategies.md +24 -1
  65. data/docs/getting-started.md +9 -1
  66. data/docs/index.md +1 -1
  67. data/docs/introduction.md +4 -4
  68. data/docs/monitoring.md +8 -3
  69. data/docs/pipeline-chaining.md +17 -9
  70. data/docs/public/screenshots/definitions.png +0 -0
  71. data/docs/public/screenshots/index.png +0 -0
  72. data/docs/public/screenshots/show.png +0 -0
  73. data/docs/screenshots/definitions.png +0 -0
  74. data/docs/screenshots/index.png +0 -0
  75. data/docs/screenshots/show.png +0 -0
  76. data/gemfiles/rails_7_2.gemfile +24 -0
  77. data/gemfiles/rails_7_2.gemfile.lock +412 -0
  78. data/gemfiles/rails_8_0.gemfile +24 -0
  79. data/gemfiles/rails_8_0.gemfile.lock +411 -0
  80. data/gemfiles/rails_8_1.gemfile +24 -0
  81. data/gemfiles/rails_8_1.gemfile.lock +415 -0
  82. data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +9 -0
  83. data/lib/generators/good_pipeline/upgrade/templates/add_good_pipeline_dashboard_indexes.rb.erb +27 -0
  84. data/lib/generators/good_pipeline/upgrade/upgrade_generator.rb +32 -0
  85. data/lib/good_pipeline/barrier_compiler.rb +106 -0
  86. data/lib/good_pipeline/chain_coordinator.rb +9 -5
  87. data/lib/good_pipeline/constants.rb +2 -0
  88. data/lib/good_pipeline/coordinator.rb +269 -140
  89. data/lib/good_pipeline/dashboard/connection_info.rb +67 -0
  90. data/lib/good_pipeline/dashboard/definition_stages.rb +59 -0
  91. data/lib/good_pipeline/dashboard/filter_set.rb +77 -0
  92. data/lib/good_pipeline/dashboard/kpi_calculator.rb +143 -0
  93. data/lib/good_pipeline/dashboard/sparkline.rb +73 -0
  94. data/lib/good_pipeline/dashboard/stage_lanes.rb +307 -0
  95. data/lib/good_pipeline/dashboard/step_timings.rb +50 -0
  96. data/lib/good_pipeline/dashboard.rb +21 -0
  97. data/lib/good_pipeline/errors.rb +11 -0
  98. data/lib/good_pipeline/pipeline.rb +14 -2
  99. data/lib/good_pipeline/step_definition.rb +16 -1
  100. data/lib/good_pipeline/version.rb +1 -1
  101. data/lib/good_pipeline.rb +7 -1
  102. data/mise.toml +1 -1
  103. metadata +48 -13
  104. data/app/frontend/good_pipeline/style.css +0 -597
  105. data/app/views/good_pipeline/pipelines/_pagination.html.erb +0 -24
  106. data/app/views/good_pipeline/pipelines/_pipeline_row.html.erb +0 -7
@@ -61,7 +61,7 @@ class TestBulkEnqueue < ActiveSupport::TestCase
61
61
  batch_record = GoodJob::BatchRecord.find(step.reload.good_job_batch_id)
62
62
 
63
63
  assert_equal "GoodPipeline::StepFinishedJob", batch_record.on_finish
64
- assert_equal({ step_id: step.id }, batch_record.properties)
64
+ assert_equal({ step_id: step.id, pipeline_id: pipeline.id }, batch_record.properties)
65
65
  end
66
66
 
67
67
  # --- enqueue_options ---
@@ -154,6 +154,22 @@ class TestBulkEnqueue < ActiveSupport::TestCase
154
154
  assert_nil result
155
155
  end
156
156
 
157
+ def test_rejects_steps_from_multiple_pipelines_before_enqueuing
158
+ pipeline_a = create_pipeline(on_failure_strategy: "halt", status: "running")
159
+ pipeline_b = create_pipeline(on_failure_strategy: "halt", status: "running")
160
+ step_a = build_step(pipeline_a, key: "step_a", job_class: "DownloadJob")
161
+ step_b = build_step(pipeline_b, key: "step_b", job_class: "TranscodeJob")
162
+
163
+ error = assert_raises(ArgumentError) do
164
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step_a.id, step_b.id])
165
+ end
166
+
167
+ assert_match(/same pipeline/, error.message)
168
+ assert_nil step_a.reload.good_job_id
169
+ assert_nil step_b.reload.good_job_id
170
+ assert_equal %w[pending pending], [step_a.coordination_status, step_b.coordination_status]
171
+ end
172
+
157
173
  # --- branch step fallback ---
158
174
 
159
175
  def test_falls_back_to_try_enqueue_step_for_branch_steps
@@ -174,6 +190,17 @@ class TestBulkEnqueue < ActiveSupport::TestCase
174
190
  "Branch step should have been processed by try_enqueue_step fallback"
175
191
  end
176
192
 
193
+ def test_falls_back_to_structural_resolution_for_barrier_steps
194
+ pipeline = create_pipeline(status: "running", on_failure_strategy: "halt")
195
+ barrier = build_step(pipeline, key: "barrier", job_class: GoodPipeline::BARRIER_JOB_CLASS)
196
+
197
+ GoodPipeline::Coordinator.bulk_enqueue_steps([barrier.id])
198
+
199
+ assert_equal "succeeded", barrier.reload.coordination_status
200
+ assert_nil barrier.good_job_id
201
+ assert_nil barrier.good_job_batch_id
202
+ end
203
+
177
204
  # --- error handling ---
178
205
 
179
206
  def test_invalid_job_class_fails_step_without_blocking_others
@@ -190,4 +217,30 @@ class TestBulkEnqueue < ActiveSupport::TestCase
190
217
  assert_equal "failed", bad_step.reload.coordination_status
191
218
  assert_equal "GoodPipeline::ConfigurationError", bad_step.error_class
192
219
  end
220
+
221
+ def test_each_bulk_configuration_failure_applies_its_halt_scope
222
+ pipeline = create_pipeline(on_failure_strategy: "halt")
223
+ pipeline.update_columns(status: "running")
224
+ ignored_failure = build_step(
225
+ pipeline,
226
+ id: "00000000-0000-4000-8000-000000000001",
227
+ key: "ignored_failure",
228
+ job_class: "MissingIgnoredJob",
229
+ on_failure_strategy: "ignore"
230
+ )
231
+ strict_failure = build_step(
232
+ pipeline,
233
+ id: "ffffffff-ffff-4fff-8fff-ffffffffffff",
234
+ key: "strict_failure",
235
+ job_class: "MissingStrictJob"
236
+ )
237
+ dependent = build_step(pipeline, key: "dependent", dependencies: [ignored_failure])
238
+
239
+ GoodPipeline::Coordinator.bulk_enqueue_steps([ignored_failure.id, strict_failure.id])
240
+
241
+ assert_equal "failed", ignored_failure.reload.coordination_status
242
+ assert_equal "failed", strict_failure.reload.coordination_status
243
+ assert_equal "skipped", dependent.reload.coordination_status
244
+ assert_equal "halted", pipeline.reload.status
245
+ end
193
246
  end
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/MethodLength
6
+ class TestCancellation < ActiveSupport::TestCase
7
+ def test_cancel_pending_pipeline_cancels_pending_steps_and_finishes
8
+ pipeline = create_pipeline(on_failure_strategy: "halt")
9
+ step_a = build_step(pipeline, key: "a")
10
+ step_b = build_step(pipeline, key: "b", dependencies: [step_a])
11
+
12
+ result = GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
13
+
14
+ assert_equal pipeline.id, result.id
15
+ assert_equal "canceled", result.status
16
+ assert_equal "canceled", pipeline.reload.status
17
+ assert_equal %w[canceled canceled], [step_a.reload.coordination_status, step_b.reload.coordination_status]
18
+ refute_nil pipeline.callbacks_dispatched_at
19
+ end
20
+
21
+ def test_cancel_running_pipeline_without_enqueued_work_finishes_immediately
22
+ pipeline = create_pipeline(on_failure_strategy: "halt")
23
+ pipeline.update_columns(status: "running")
24
+ step = build_step(pipeline, key: "a")
25
+
26
+ GoodPipeline::Coordinator.cancel_pipeline(pipeline)
27
+
28
+ assert_equal "canceled", pipeline.reload.status
29
+ assert_equal "canceled", step.reload.coordination_status
30
+ end
31
+
32
+ def test_cancel_running_pipeline_drains_enqueued_work
33
+ pipeline = create_pipeline(on_failure_strategy: "halt")
34
+ pipeline.update_columns(status: "running")
35
+ enqueued_step = build_step(pipeline, key: "enqueued")
36
+ pending_step = build_step(pipeline, key: "pending", dependencies: [enqueued_step])
37
+ enqueued_step.update_columns(coordination_status: "enqueued")
38
+
39
+ result = GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
40
+
41
+ assert_equal "canceling", result.status
42
+ assert_equal "canceling", pipeline.reload.status
43
+ assert_equal "enqueued", enqueued_step.reload.coordination_status
44
+ assert_equal "canceled", pending_step.reload.coordination_status
45
+ assert_nil pipeline.callbacks_dispatched_at
46
+ end
47
+
48
+ def test_cancel_is_idempotent_while_canceling_and_after_canceled
49
+ pipeline = create_pipeline(on_failure_strategy: "halt")
50
+ pipeline.update_columns(status: "running")
51
+ step = build_step(pipeline, key: "a")
52
+ step.update_columns(coordination_status: "enqueued")
53
+
54
+ first = GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
55
+ second = GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
56
+
57
+ assert_equal "canceling", first.status
58
+ assert_equal "canceling", second.status
59
+ assert_nil pipeline.reload.callbacks_dispatched_at
60
+
61
+ GoodPipeline::Coordinator.complete_step(step.id, succeeded: true)
62
+ dispatched_at = pipeline.reload.callbacks_dispatched_at
63
+ third = GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
64
+
65
+ assert_equal "canceled", third.status
66
+ assert_equal dispatched_at, pipeline.reload.callbacks_dispatched_at
67
+ assert_equal 1, callback_jobs_for(pipeline).count
68
+ end
69
+
70
+ def test_cancel_rejects_unrelated_terminal_status
71
+ %w[succeeded failed halted skipped].each do |status|
72
+ pipeline = create_pipeline(on_failure_strategy: "halt")
73
+ pipeline.update_columns(status: status)
74
+
75
+ error = assert_raises(GoodPipeline::CancellationConflict) do
76
+ GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
77
+ end
78
+
79
+ assert_equal pipeline.id, error.pipeline_id
80
+ assert_equal status, error.status
81
+ end
82
+ end
83
+
84
+ def test_terminal_transition_and_callback_enqueue_roll_back_together
85
+ pipeline = create_pipeline(on_failure_strategy: "halt")
86
+ build_step(pipeline, key: "a")
87
+
88
+ GoodPipeline::PipelineRecord.transaction do
89
+ GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
90
+ raise ActiveRecord::Rollback
91
+ end
92
+
93
+ assert_equal "pending", pipeline.reload.status
94
+ assert_nil pipeline.callbacks_dispatched_at
95
+ assert_empty callback_jobs_for(pipeline)
96
+ end
97
+
98
+ def test_single_enqueue_is_guarded_once_canceling
99
+ pipeline = create_pipeline(on_failure_strategy: "halt")
100
+ pipeline.update_columns(status: "canceling")
101
+ step = build_step(pipeline, key: "a")
102
+
103
+ result = GoodPipeline::Coordinator.try_enqueue_step(step.id)
104
+
105
+ refute result
106
+ assert_equal "pending", step.reload.coordination_status
107
+ assert_nil step.good_job_id
108
+ end
109
+
110
+ def test_bulk_enqueue_is_guarded_once_canceling
111
+ pipeline = create_pipeline(on_failure_strategy: "halt")
112
+ pipeline.update_columns(status: "canceling")
113
+ step = build_step(pipeline, key: "a")
114
+
115
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step.id])
116
+
117
+ assert_equal "pending", step.reload.coordination_status
118
+ assert_nil step.good_job_id
119
+ end
120
+
121
+ def test_successful_completion_during_drain_preserves_outcome_and_finalizes
122
+ pipeline, step = canceling_pipeline_with_enqueued_step
123
+
124
+ GoodPipeline::Coordinator.complete_step(step, succeeded: true)
125
+
126
+ assert_equal "succeeded", step.reload.coordination_status
127
+ assert_equal "canceled", pipeline.reload.status
128
+ end
129
+
130
+ def test_failed_completion_during_drain_preserves_outcome_and_metadata
131
+ pipeline, step = canceling_pipeline_with_enqueued_step
132
+ create_good_job_for_step(step, error: "RuntimeError: drain failed", executions_count: 2)
133
+
134
+ GoodPipeline::Coordinator.complete_step(step.id, succeeded: false)
135
+
136
+ step.reload
137
+
138
+ assert_equal "failed", step.coordination_status
139
+ assert_equal "RuntimeError", step.error_class
140
+ assert_equal "drain failed", step.error_message
141
+ assert_equal 2, step.attempts
142
+ assert_equal "canceled", pipeline.reload.status
143
+ refute_predicate pipeline, :halt_triggered?
144
+ end
145
+
146
+ def test_halt_requested_completion_during_drain_preserves_halted_step
147
+ pipeline, step = canceling_pipeline_with_enqueued_step
148
+ step.update_columns(halt_requested: true)
149
+
150
+ GoodPipeline::Coordinator.complete_step(step.id, succeeded: true)
151
+
152
+ assert_equal "halted", step.reload.coordination_status
153
+ assert_equal "canceled", pipeline.reload.status
154
+ end
155
+
156
+ def test_only_last_enqueued_completion_finalizes_cancellation
157
+ pipeline = create_pipeline(on_failure_strategy: "halt")
158
+ pipeline.update_columns(status: "running")
159
+ step_a = build_step(pipeline, key: "a")
160
+ step_b = build_step(pipeline, key: "b")
161
+ [step_a, step_b].each { |step| step.update_columns(coordination_status: "enqueued") }
162
+ GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
163
+
164
+ GoodPipeline::Coordinator.complete_step(step_a.id, succeeded: true)
165
+
166
+ assert_equal "canceling", pipeline.reload.status
167
+ assert_nil pipeline.callbacks_dispatched_at
168
+
169
+ GoodPipeline::Coordinator.complete_step(step_b.id, succeeded: false)
170
+
171
+ assert_equal "canceled", pipeline.reload.status
172
+ refute_nil pipeline.callbacks_dispatched_at
173
+ assert_equal 1, callback_jobs_for(pipeline).count
174
+ end
175
+
176
+ def test_recompute_uses_fresh_activity_and_prioritizes_cancellation
177
+ pipeline = create_pipeline(on_failure_strategy: "halt")
178
+ pipeline.update_columns(status: "canceling")
179
+ step = build_step(pipeline, key: "a")
180
+ pipeline.steps.load
181
+ step.update_columns(coordination_status: "succeeded")
182
+
183
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline)
184
+
185
+ assert_equal "canceled", pipeline.reload.status
186
+ end
187
+
188
+ def test_cancel_preserves_real_scheduled_good_job
189
+ pipeline = create_pipeline(on_failure_strategy: "halt")
190
+ pipeline.update_columns(status: "running")
191
+ step = build_step(
192
+ pipeline,
193
+ key: "scheduled",
194
+ enqueue_options: { "wait" => 3600, "queue" => "slow" }
195
+ )
196
+ dependent = build_step(pipeline, key: "dependent", dependencies: [step])
197
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step.id])
198
+ step.reload
199
+ good_job = GoodJob::Job.find(step.good_job_id)
200
+ job_count = GoodJob::Job.count
201
+ step_job_ids = [step.good_job_id, step.good_job_batch_id]
202
+ job_snapshot = good_job.attributes.slice("id", "batch_id", "scheduled_at", "finished_at", "queue_name", "priority")
203
+
204
+ GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
205
+
206
+ assert_equal "canceling", pipeline.reload.status
207
+ assert_equal "enqueued", step.reload.coordination_status
208
+ assert_equal step_job_ids, [step.good_job_id, step.good_job_batch_id]
209
+ assert_equal "canceled", dependent.reload.coordination_status
210
+ assert_equal job_count, GoodJob::Job.count
211
+ assert_equal job_snapshot, good_job.reload.attributes.slice(*job_snapshot.keys)
212
+ assert_operator good_job.scheduled_at, :>, Time.current
213
+
214
+ GoodPipeline::Coordinator.complete_step(step.id, succeeded: true)
215
+
216
+ assert_equal "succeeded", step.reload.coordination_status
217
+ assert_equal "canceled", pipeline.reload.status
218
+ end
219
+
220
+ def test_cancel_and_enqueue_race_has_only_serializable_outcomes
221
+ 5.times do |iteration|
222
+ pipeline = create_pipeline(on_failure_strategy: "halt")
223
+ pipeline.update_columns(status: "running")
224
+ step = build_step(pipeline, key: "race_#{iteration}")
225
+ latch = Concurrent::CountDownLatch.new(2)
226
+
227
+ cancel = rails_promise do
228
+ latch.count_down
229
+ latch.wait(5)
230
+ GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
231
+ end
232
+ enqueue = rails_promise do
233
+ latch.count_down
234
+ latch.wait(5)
235
+ GoodPipeline::Coordinator.try_enqueue_step(step.id)
236
+ end
237
+
238
+ cancel.value!
239
+ enqueue.value!
240
+
241
+ assert_serialized_cancel_enqueue_outcome(pipeline, step, iteration)
242
+ end
243
+ end
244
+
245
+ def test_cancel_and_bulk_enqueue_race_has_only_serializable_outcomes
246
+ 5.times do |iteration|
247
+ pipeline = create_pipeline(on_failure_strategy: "halt")
248
+ pipeline.update_columns(status: "running")
249
+ step = build_step(pipeline, key: "bulk_race_#{iteration}")
250
+ latch = Concurrent::CountDownLatch.new(2)
251
+
252
+ cancel = rails_promise do
253
+ latch.count_down
254
+ latch.wait(5)
255
+ GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
256
+ end
257
+ enqueue = rails_promise do
258
+ latch.count_down
259
+ latch.wait(5)
260
+ GoodPipeline::Coordinator.bulk_enqueue_steps([step.id])
261
+ end
262
+
263
+ cancel.value!
264
+ enqueue.value!
265
+
266
+ assert_serialized_cancel_enqueue_outcome(pipeline, step, iteration)
267
+ end
268
+ end
269
+
270
+ private
271
+
272
+ def canceling_pipeline_with_enqueued_step
273
+ pipeline = create_pipeline(on_failure_strategy: "halt")
274
+ pipeline.update_columns(status: "running")
275
+ step = build_step(pipeline, key: "a")
276
+ step.update_columns(coordination_status: "enqueued")
277
+ GoodPipeline::Coordinator.cancel_pipeline(pipeline.id)
278
+ [pipeline, step]
279
+ end
280
+
281
+ def create_good_job_for_step(step, error:, executions_count:)
282
+ good_job = GoodJob::Job.create!(
283
+ id: SecureRandom.uuid,
284
+ active_job_id: SecureRandom.uuid,
285
+ job_class: step.job_class,
286
+ error: error,
287
+ executions_count: executions_count,
288
+ finished_at: Time.current
289
+ )
290
+ step.update_column(:good_job_id, good_job.id)
291
+ end
292
+
293
+ def callback_jobs_for(pipeline)
294
+ GoodJob::Job.where(job_class: "GoodPipeline::PipelineCallbackJob")
295
+ .where("serialized_params -> 'arguments' ->> 0 = ?", pipeline.id)
296
+ end
297
+
298
+ def assert_serialized_cancel_enqueue_outcome(pipeline, step, iteration)
299
+ pipeline.reload
300
+ step.reload
301
+
302
+ if pipeline.canceled?
303
+ assert_equal "canceled", step.coordination_status, "iteration #{iteration}"
304
+ assert_nil step.good_job_id, "iteration #{iteration}"
305
+ else
306
+ assert_equal "canceling", pipeline.status, "iteration #{iteration}"
307
+ assert_equal "enqueued", step.coordination_status, "iteration #{iteration}"
308
+ refute_nil step.good_job_id, "iteration #{iteration}"
309
+ end
310
+ end
311
+ end
312
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/MethodLength
@@ -28,6 +28,9 @@ class TestCleanup < ActiveSupport::TestCase
28
28
  pipeline: @running_pipeline, key: "step_a", job_class: "DownloadJob", coordination_status: "enqueued"
29
29
  )
30
30
 
31
+ @canceling_pipeline = create_old_pipeline(status: "canceling", step_status: "enqueued")
32
+ @canceled_pipeline = create_old_pipeline(status: "canceled", step_status: "canceled")
33
+
31
34
  # Recent terminal pipeline (should NOT be cleaned up)
32
35
  @recent_pipeline = GoodPipeline::PipelineRecord.create!(
33
36
  type: "TestPipeline", status: "failed", on_failure_strategy: "halt"
@@ -56,6 +59,18 @@ class TestCleanup < ActiveSupport::TestCase
56
59
  assert_not_nil GoodPipeline::PipelineRecord.find_by(id: @running_pipeline.id)
57
60
  end
58
61
 
62
+ test "preserves canceling pipelines" do
63
+ GoodPipeline.cleanup_preserved_pipelines(older_than: @now - 14.days)
64
+
65
+ assert_not_nil GoodPipeline::PipelineRecord.find_by(id: @canceling_pipeline.id)
66
+ end
67
+
68
+ test "cleans canceled pipelines" do
69
+ GoodPipeline.cleanup_preserved_pipelines(older_than: @now - 14.days)
70
+
71
+ assert_nil GoodPipeline::PipelineRecord.find_by(id: @canceled_pipeline.id)
72
+ end
73
+
59
74
  test "preserves recent terminal pipelines" do
60
75
  GoodPipeline.cleanup_preserved_pipelines(older_than: @now - 14.days)
61
76
 
@@ -80,14 +95,29 @@ class TestCleanup < ActiveSupport::TestCase
80
95
  test "noop when nothing to clean" do
81
96
  GoodPipeline.cleanup_preserved_pipelines(older_than: @now - 365.days)
82
97
 
83
- assert_equal 3, GoodPipeline::PipelineRecord.count
98
+ assert_equal 5, GoodPipeline::PipelineRecord.count
84
99
  end
85
100
 
86
101
  test "triggers cleanup when GoodJob cleans preserved jobs" do
87
102
  GoodJob.cleanup_preserved_jobs(older_than: 14.days)
88
103
 
89
104
  assert_nil GoodPipeline::PipelineRecord.find_by(id: @old_pipeline.id)
105
+ assert_nil GoodPipeline::PipelineRecord.find_by(id: @canceled_pipeline.id)
90
106
  assert_not_nil GoodPipeline::PipelineRecord.find_by(id: @running_pipeline.id)
107
+ assert_not_nil GoodPipeline::PipelineRecord.find_by(id: @canceling_pipeline.id)
91
108
  assert_not_nil GoodPipeline::PipelineRecord.find_by(id: @recent_pipeline.id)
92
109
  end
110
+
111
+ private
112
+
113
+ def create_old_pipeline(status:, step_status:)
114
+ pipeline = GoodPipeline::PipelineRecord.create!(
115
+ type: "TestPipeline", status: status, on_failure_strategy: "halt"
116
+ )
117
+ pipeline.update_columns(updated_at: @now - 30.days)
118
+ GoodPipeline::StepRecord.create!(
119
+ pipeline: pipeline, key: "step_a", job_class: "DownloadJob", coordination_status: step_status
120
+ )
121
+ pipeline
122
+ end
93
123
  end
@@ -31,6 +31,18 @@ class TestCoordinator < ActiveSupport::TestCase
31
31
  assert_equal "succeeded", step.reload.coordination_status
32
32
  end
33
33
 
34
+ def test_terminal_step_redelivery_repairs_running_pipeline_status
35
+ pipeline = create_pipeline(on_failure_strategy: "halt")
36
+ pipeline.update_columns(status: "running")
37
+ step = build_step(pipeline, key: "a")
38
+ step.update_columns(coordination_status: "succeeded")
39
+
40
+ GoodPipeline::Coordinator.complete_step(step.id, succeeded: true)
41
+
42
+ assert_equal "succeeded", pipeline.reload.status
43
+ refute_nil pipeline.callbacks_dispatched_at
44
+ end
45
+
34
46
  # --- complete_step: succeeded ---
35
47
 
36
48
  def test_complete_step_succeeded_transitions
@@ -127,6 +139,14 @@ class TestCoordinator < ActiveSupport::TestCase
127
139
  assert_equal "succeeded", pipeline.reload.status
128
140
  end
129
141
 
142
+ def test_recompute_rejects_removed_activity_hints
143
+ pipeline = create_pipeline(on_failure_strategy: "halt")
144
+
145
+ assert_raises(ArgumentError) do
146
+ GoodPipeline::Coordinator.recompute_pipeline_status(pipeline, has_active_steps: true)
147
+ end
148
+ end
149
+
130
150
  # --- dispatch_callbacks_once ---
131
151
 
132
152
  def test_dispatch_callbacks_sets_callbacks_dispatched_at
@@ -261,6 +281,24 @@ class TestCoordinator < ActiveSupport::TestCase
261
281
 
262
282
  # --- try_enqueue_step ---
263
283
 
284
+ def test_try_enqueue_batch_includes_pipeline_ownership
285
+ pipeline = create_pipeline(on_failure_strategy: "halt")
286
+ pipeline.update_columns(status: "running")
287
+ step = build_step(pipeline, key: "a")
288
+
289
+ GoodPipeline::Coordinator.try_enqueue_step(step.id)
290
+
291
+ batch = GoodJob::BatchRecord.find(step.reload.good_job_batch_id)
292
+
293
+ assert_equal({ step_id: step.id, pipeline_id: pipeline.id }, batch.properties)
294
+ end
295
+
296
+ def test_try_enqueue_returns_false_for_missing_step
297
+ result = GoodPipeline::Coordinator.try_enqueue_step(SecureRandom.uuid)
298
+
299
+ refute result
300
+ end
301
+
264
302
  def test_try_enqueue_bails_on_non_pending_step
265
303
  pipeline = create_pipeline(on_failure_strategy: "halt")
266
304
  step = build_step(pipeline, key: "a")
@@ -293,6 +331,7 @@ class TestCoordinator < ActiveSupport::TestCase
293
331
 
294
332
  def test_try_enqueue_skips_permanently_unsatisfied_step
295
333
  pipeline = create_pipeline(on_failure_strategy: "continue")
334
+ pipeline.update_columns(status: "running")
296
335
  step_a = build_step(pipeline, key: "a")
297
336
  step_b = build_step(pipeline, key: "b", dependencies: [step_a])
298
337
  step_a.update_columns(coordination_status: "failed")