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.
- checksums.yaml +4 -4
- data/Appraisals +25 -0
- data/CHANGELOG.md +58 -0
- data/README.md +67 -11
- data/app/controllers/good_pipeline/frontends_controller.rb +18 -1
- data/app/controllers/good_pipeline/pipelines_controller.rb +254 -36
- data/app/controllers/good_pipeline/themes_controller.rb +15 -0
- data/app/frontend/good_pipeline/dashboard.css +407 -0
- data/app/frontend/good_pipeline/dashboard.js +542 -0
- data/app/helpers/good_pipeline/mermaid_diagram_builder.rb +225 -66
- data/app/helpers/good_pipeline/pipelines_helper.rb +89 -5
- data/app/jobs/good_pipeline/pipeline_callback_job.rb +2 -2
- data/app/jobs/good_pipeline/pipeline_reconciliation_job.rb +1 -2
- data/app/jobs/good_pipeline/step_finished_job.rb +6 -2
- data/app/models/good_pipeline/pipeline_record.rb +7 -4
- data/app/models/good_pipeline/step_record.rb +6 -3
- data/app/views/good_pipeline/pipelines/_actions.html.erb +28 -0
- data/app/views/good_pipeline/pipelines/_chain_links.html.erb +17 -29
- data/app/views/good_pipeline/pipelines/_diagram.html.erb +101 -0
- data/app/views/good_pipeline/pipelines/_filter_bar.html.erb +51 -0
- data/app/views/good_pipeline/pipelines/_kpi_strip.html.erb +74 -0
- data/app/views/good_pipeline/pipelines/_pager.html.erb +31 -0
- data/app/views/good_pipeline/pipelines/_row.html.erb +103 -0
- data/app/views/good_pipeline/pipelines/_row_detail.html.erb +93 -0
- data/app/views/good_pipeline/pipelines/_sidebar.html.erb +115 -0
- data/app/views/good_pipeline/pipelines/_steps_table.html.erb +46 -33
- data/app/views/good_pipeline/pipelines/_table.html.erb +33 -0
- data/app/views/good_pipeline/pipelines/_timeline.html.erb +55 -0
- data/app/views/good_pipeline/pipelines/_topbar.html.erb +54 -0
- data/app/views/good_pipeline/pipelines/definitions.html.erb +88 -55
- data/app/views/good_pipeline/pipelines/index.html.erb +31 -42
- data/app/views/good_pipeline/pipelines/show.html.erb +54 -47
- data/app/views/layouts/good_pipeline/application.html.erb +24 -153
- data/config/routes.rb +4 -0
- data/demo/config/environments/development.rb +3 -0
- data/demo/config/environments/test.rb +2 -1
- data/demo/db/migrate/20260319205325_create_good_jobs.rb +1 -1
- data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +1 -1
- data/demo/db/migrate/20260810000000_add_good_pipeline_dashboard_indexes.rb +20 -0
- data/demo/db/seeds.rb +325 -11
- data/demo/docs/screenshots/definitions.png +0 -0
- data/demo/docs/screenshots/show.png +0 -0
- data/demo/test/good_pipeline/test_bulk_enqueue.rb +54 -1
- data/demo/test/good_pipeline/test_cancellation.rb +312 -0
- data/demo/test/good_pipeline/test_cleanup.rb +31 -1
- data/demo/test/good_pipeline/test_coordinator.rb +39 -0
- data/demo/test/good_pipeline/test_dashboard.rb +667 -0
- data/demo/test/good_pipeline/test_dashboard_rerun.rb +244 -0
- data/demo/test/good_pipeline/test_pipeline_callback_job.rb +11 -0
- data/demo/test/good_pipeline/test_pipeline_record.rb +30 -0
- data/demo/test/good_pipeline/test_runner.rb +40 -1
- data/demo/test/good_pipeline/test_step_record.rb +37 -0
- data/demo/test/integration/test_barrier_execution.rb +502 -0
- data/demo/test/integration/test_chain_locking.rb +54 -0
- data/demo/test/integration/test_pipeline_chaining.rb +96 -0
- data/demo/test/test_helper.rb +20 -0
- data/docs/architecture.md +45 -29
- data/docs/branching.md +4 -0
- data/docs/callbacks.md +9 -7
- data/docs/cleanup.md +2 -2
- data/docs/dag-validation.md +16 -0
- data/docs/dashboard.md +84 -22
- data/docs/defining-pipelines.md +34 -1
- data/docs/failure-strategies.md +24 -1
- data/docs/getting-started.md +9 -1
- data/docs/index.md +1 -1
- data/docs/introduction.md +4 -4
- data/docs/monitoring.md +8 -3
- data/docs/pipeline-chaining.md +17 -9
- data/docs/public/screenshots/definitions.png +0 -0
- data/docs/public/screenshots/index.png +0 -0
- data/docs/public/screenshots/show.png +0 -0
- data/docs/screenshots/definitions.png +0 -0
- data/docs/screenshots/index.png +0 -0
- data/docs/screenshots/show.png +0 -0
- data/gemfiles/rails_7_2.gemfile +24 -0
- data/gemfiles/rails_7_2.gemfile.lock +412 -0
- data/gemfiles/rails_8_0.gemfile +24 -0
- data/gemfiles/rails_8_0.gemfile.lock +411 -0
- data/gemfiles/rails_8_1.gemfile +24 -0
- data/gemfiles/rails_8_1.gemfile.lock +415 -0
- data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +9 -0
- data/lib/generators/good_pipeline/upgrade/templates/add_good_pipeline_dashboard_indexes.rb.erb +27 -0
- data/lib/generators/good_pipeline/upgrade/upgrade_generator.rb +32 -0
- data/lib/good_pipeline/barrier_compiler.rb +106 -0
- data/lib/good_pipeline/chain_coordinator.rb +9 -5
- data/lib/good_pipeline/constants.rb +2 -0
- data/lib/good_pipeline/coordinator.rb +269 -140
- data/lib/good_pipeline/dashboard/connection_info.rb +67 -0
- data/lib/good_pipeline/dashboard/definition_stages.rb +59 -0
- data/lib/good_pipeline/dashboard/filter_set.rb +77 -0
- data/lib/good_pipeline/dashboard/kpi_calculator.rb +143 -0
- data/lib/good_pipeline/dashboard/sparkline.rb +73 -0
- data/lib/good_pipeline/dashboard/stage_lanes.rb +307 -0
- data/lib/good_pipeline/dashboard/step_timings.rb +50 -0
- data/lib/good_pipeline/dashboard.rb +21 -0
- data/lib/good_pipeline/errors.rb +11 -0
- data/lib/good_pipeline/pipeline.rb +14 -2
- data/lib/good_pipeline/step_definition.rb +16 -1
- data/lib/good_pipeline/version.rb +1 -1
- data/lib/good_pipeline.rb +7 -1
- data/mise.toml +1 -1
- metadata +48 -13
- data/app/frontend/good_pipeline/style.css +0 -597
- data/app/views/good_pipeline/pipelines/_pagination.html.erb +0 -24
- 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
|
|
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")
|