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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +217 -0
- data/Rakefile +20 -0
- data/app/controllers/good_pipeline/application_controller.rb +9 -0
- data/app/controllers/good_pipeline/frontends_controller.rb +31 -0
- data/app/controllers/good_pipeline/pipelines_controller.rb +57 -0
- data/app/frontend/good_pipeline/style.css +518 -0
- data/app/helpers/good_pipeline/pipelines_helper.rb +119 -0
- data/app/jobs/good_pipeline/pipeline_callback_job.rb +52 -0
- data/app/jobs/good_pipeline/pipeline_reconciliation_job.rb +10 -0
- data/app/jobs/good_pipeline/step_finished_job.rb +10 -0
- data/app/models/good_pipeline/chain_record.rb +18 -0
- data/app/models/good_pipeline/dependency_record.rb +23 -0
- data/app/models/good_pipeline/pipeline_record.rb +73 -0
- data/app/models/good_pipeline/step_record.rb +74 -0
- data/app/views/good_pipeline/pipelines/_chain_links.html.erb +30 -0
- data/app/views/good_pipeline/pipelines/_pagination.html.erb +24 -0
- data/app/views/good_pipeline/pipelines/_pipeline_row.html.erb +7 -0
- data/app/views/good_pipeline/pipelines/_steps_table.html.erb +33 -0
- data/app/views/good_pipeline/pipelines/definitions.html.erb +49 -0
- data/app/views/good_pipeline/pipelines/index.html.erb +43 -0
- data/app/views/good_pipeline/pipelines/show.html.erb +40 -0
- data/app/views/layouts/good_pipeline/application.html.erb +40 -0
- data/config/routes.rb +13 -0
- data/demo/Rakefile +5 -0
- data/demo/app/jobs/always_failing_job.rb +12 -0
- data/demo/app/jobs/application_job.rb +4 -0
- data/demo/app/jobs/cleanup_job.rb +5 -0
- data/demo/app/jobs/download_job.rb +5 -0
- data/demo/app/jobs/failing_job.rb +12 -0
- data/demo/app/jobs/publish_job.rb +5 -0
- data/demo/app/jobs/retryable_job.rb +19 -0
- data/demo/app/jobs/thumbnail_job.rb +5 -0
- data/demo/app/jobs/transcode_job.rb +5 -0
- data/demo/app/pipelines/analytics_pipeline.rb +7 -0
- data/demo/app/pipelines/archive_pipeline.rb +7 -0
- data/demo/app/pipelines/continue_test_pipeline.rb +11 -0
- data/demo/app/pipelines/halt_test_pipeline.rb +10 -0
- data/demo/app/pipelines/notification_pipeline.rb +7 -0
- data/demo/app/pipelines/test_pipeline.rb +5 -0
- data/demo/app/pipelines/video_processing_pipeline.rb +14 -0
- data/demo/bin/rails +6 -0
- data/demo/config/application.rb +18 -0
- data/demo/config/boot.rb +5 -0
- data/demo/config/database.yml +15 -0
- data/demo/config/environment.rb +5 -0
- data/demo/config/environments/development.rb +9 -0
- data/demo/config/environments/test.rb +10 -0
- data/demo/config/routes.rb +6 -0
- data/demo/config.ru +5 -0
- data/demo/db/migrate/20260319205325_create_good_jobs.rb +112 -0
- data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +53 -0
- data/demo/db/seeds.rb +153 -0
- data/demo/test/good_pipeline/test_chain_record.rb +29 -0
- data/demo/test/good_pipeline/test_cleanup.rb +93 -0
- data/demo/test/good_pipeline/test_coordinator.rb +286 -0
- data/demo/test/good_pipeline/test_dependency_record.rb +46 -0
- data/demo/test/good_pipeline/test_failure_metadata.rb +77 -0
- data/demo/test/good_pipeline/test_introspection.rb +46 -0
- data/demo/test/good_pipeline/test_pipeline_callback_job.rb +132 -0
- data/demo/test/good_pipeline/test_pipeline_reconciliation_job.rb +33 -0
- data/demo/test/good_pipeline/test_pipeline_record.rb +183 -0
- data/demo/test/good_pipeline/test_runner.rb +86 -0
- data/demo/test/good_pipeline/test_step_finished_job.rb +37 -0
- data/demo/test/good_pipeline/test_step_record.rb +208 -0
- data/demo/test/integration/test_concurrent_fan_in.rb +109 -0
- data/demo/test/integration/test_end_to_end.rb +89 -0
- data/demo/test/integration/test_enqueue_atomicity.rb +59 -0
- data/demo/test/integration/test_pipeline_chaining.rb +183 -0
- data/demo/test/integration/test_retry_scenarios.rb +90 -0
- data/demo/test/integration/test_step_finished_idempotency.rb +38 -0
- data/demo/test/test_helper.rb +71 -0
- data/dev-docker-compose.yml +16 -0
- data/docs/.vitepress/config.mts +66 -0
- data/docs/.vitepress/theme/custom.css +21 -0
- data/docs/.vitepress/theme/index.ts +4 -0
- data/docs/architecture.md +184 -0
- data/docs/callbacks.md +66 -0
- data/docs/cleanup.md +45 -0
- data/docs/dag-validation.md +88 -0
- data/docs/dashboard.md +66 -0
- data/docs/defining-pipelines.md +167 -0
- data/docs/failure-strategies.md +138 -0
- data/docs/getting-started.md +77 -0
- data/docs/index.md +23 -0
- data/docs/introduction.md +42 -0
- data/docs/monitoring.md +103 -0
- data/docs/package-lock.json +2510 -0
- data/docs/package.json +11 -0
- data/docs/pipeline-chaining.md +104 -0
- 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/lib/generators/good_pipeline/install/install_generator.rb +20 -0
- data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +51 -0
- data/lib/good_pipeline/chain.rb +54 -0
- data/lib/good_pipeline/chain_coordinator.rb +53 -0
- data/lib/good_pipeline/coordinator.rb +176 -0
- data/lib/good_pipeline/cycle_detector.rb +36 -0
- data/lib/good_pipeline/engine.rb +23 -0
- data/lib/good_pipeline/errors.rb +11 -0
- data/lib/good_pipeline/failure_metadata.rb +29 -0
- data/lib/good_pipeline/graph_validator.rb +71 -0
- data/lib/good_pipeline/pipeline.rb +122 -0
- data/lib/good_pipeline/runner.rb +63 -0
- data/lib/good_pipeline/step_definition.rb +18 -0
- data/lib/good_pipeline/version.rb +5 -0
- data/lib/good_pipeline.rb +45 -0
- data/mise.toml +10 -0
- data/sig/good_pipeline.rbs +4 -0
- 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
|