good_pipeline 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8d45ee63fb102b327e2e847cd277c911c625711562117c172119b3384d08447
4
- data.tar.gz: 888ac688b1e600a74fbc70b36c4ab0aa1d7747d4f986bd8cc9d688a6ab8c8dbc
3
+ metadata.gz: 4294d8f713a4aa48f6d9680f1b3d1c264f9ff51ec5fb0f38c1347bf3f7f44e19
4
+ data.tar.gz: 428d2ba77ef93e773b6016c3220eeb76174fcdea9463f71e3b150766e66f15e8
5
5
  SHA512:
6
- metadata.gz: 642f603b2991bb82256510cf3ea1a13f511f7009a7ba82f10d9fb8c264acdc27d213629842414293a6728c13d055ecf808ba71c555fc9bf4705f3b3b8a23ab5f
7
- data.tar.gz: 7941dcef31ca4d86f5fb796803a61d3c3d9af3ed8442aaace983c5a6c5d62360c16a4458ab7130f34764d14e37d53cc79167e4296a52b31bc0381300a3bc74b6
6
+ metadata.gz: df4a09f4fe2b67119f633e10ef83a05f24315b0017b8b634acfca6a9efc18182b9c6419cab8e0b1cdd0587be694e1c84c779b85c4875b5aef4ee13e4eedc3388
7
+ data.tar.gz: 975e92dfb856c37fa1806951798fbb449b4c1e6bec3df3bb241c1621c1bf3f88b81e61c3c039c3f0a64ea6620c084048b5a8ba5afb959921edadf9c533210745
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-10-29
4
+
5
+ ### Breaking changes
6
+
7
+ - **Chain failure strategies** — chained pipelines now use each upstream pipeline's stored `failure_strategy`. An upstream that succeeds or fails with `:ignore` satisfies its chain dependency; any other terminal outcome skips dependents. Downstream pipelines wait for every dependency, including when chains are registered after an upstream finishes. Ignored failures keep their `failed` status and failure callbacks. No new setting or migration is required.
8
+ - **Ruby 4 minimum** — GoodPipeline now requires Ruby 4.0 or newer. Development, CI, and gem releases use Ruby 4.0.9.
9
+
3
10
  ## [0.5.0] - 2026-09-01
4
11
 
5
12
  ### Added
data/README.md CHANGED
@@ -6,7 +6,7 @@ Define multi-step workflows as directed acyclic graphs — not linear chains. St
6
6
 
7
7
  ## Requirements
8
8
 
9
- - Ruby >= 3.2
9
+ - Ruby >= 4.0
10
10
  - Rails >= 7.2
11
11
  - PostgreSQL
12
12
  - GoodJob >= 4.14 with `preserve_job_records = true`
@@ -126,7 +126,7 @@ Set at the pipeline level with `failure_strategy`:
126
126
  |---|---|
127
127
  | `:halt` (default) | Stop all pending steps when any step fails |
128
128
  | `:continue` | Let independent branches continue; skip only blocked downstream steps |
129
- | `:ignore` | Treat failures as successes for dependency resolution |
129
+ | `:ignore` | Treat failed steps and pipelines as satisfied for dependency resolution |
130
130
 
131
131
  Per-step overrides via `on_failure:` in `run` apply to that step's outgoing edges only.
132
132
 
@@ -201,7 +201,7 @@ GoodPipeline.run(
201
201
  ).then(MergeMediaPipeline, with: { video_id: 123, audio_id: 456 })
202
202
  ```
203
203
 
204
- If an upstream pipeline fails, halts, is canceled, or is skipped, downstream pipelines are automatically skipped.
204
+ Chained pipelines wait for every upstream to succeed or fail with `failure_strategy :ignore`. Any other terminal outcome skips dependent pipelines. Ignored failures keep their `failed` status and still trigger failure callbacks.
205
205
 
206
206
  ### Monitoring
207
207
 
@@ -32,15 +32,16 @@ module GoodPipeline
32
32
  assert_select ".gp-status-filter-scroll > .gp-segment[role=group][aria-label='Status filter']", count: 1
33
33
  assert_includes response.body, %(id="gp-table")
34
34
  assert_no_match(/data-href/, response.body)
35
- asset_href = response.body[%r{href="([^"]*/frontend/static/0-5-0/dashboard\.css)"}, 1]
35
+ asset_version = GoodPipeline::VERSION.tr(".", "-")
36
+ asset_href = response.body[%r{href="([^"]*/frontend/static/#{asset_version}/dashboard\.css)"}, 1]
36
37
 
37
- assert asset_href&.end_with?("/frontend/static/0-5-0/dashboard.css")
38
+ assert asset_href&.end_with?("/frontend/static/#{asset_version}/dashboard.css")
38
39
  refute_includes response.body, "style.css"
39
40
  refute_includes response.body, "<script>"
40
41
 
41
42
  assert_dashboard_css(asset_href)
42
43
 
43
- get "/good_pipeline/frontend/static/0-5-0/dashboard.js"
44
+ get "/good_pipeline/frontend/static/#{asset_version}/dashboard.js"
44
45
 
45
46
  assert_response :success
46
47
  assert_includes response.media_type, "javascript"
@@ -2,6 +2,10 @@
2
2
 
3
3
  require "test_helper"
4
4
 
5
+ class IgnoreChainPipeline < ContinueTestPipeline
6
+ failure_strategy :ignore
7
+ end
8
+
5
9
  class TestPipelineChaining < ActiveSupport::TestCase
6
10
  def run_all_to_completion(pipeline_records, timeout: 15)
7
11
  deadline = Time.current + timeout
@@ -46,6 +50,32 @@ class TestPipelineChaining < ActiveSupport::TestCase
46
50
  assert_equal "skipped", downstream.status
47
51
  end
48
52
 
53
+ def test_ignored_failure_stays_failed_and_allows_the_chain_to_finish
54
+ IgnoreChainPipeline.run
55
+ .then(NotificationPipeline, with: {})
56
+ .then(ArchivePipeline, with: {})
57
+
58
+ run_all_to_completion(GoodPipeline::PipelineRecord.all.to_a)
59
+
60
+ assert_equal(
61
+ { "IgnoreChainPipeline" => "failed", "NotificationPipeline" => "succeeded", "ArchivePipeline" => "succeeded" },
62
+ GoodPipeline::PipelineRecord.pluck(:type, :status).to_h
63
+ )
64
+ end
65
+
66
+ def test_continue_failure_skips_dependents_even_when_they_use_ignore
67
+ ContinueTestPipeline.run
68
+ .then(IgnoreChainPipeline, with: {})
69
+ .then(NotificationPipeline, with: {})
70
+
71
+ run_all_to_completion(GoodPipeline::PipelineRecord.all.to_a)
72
+
73
+ assert_equal(
74
+ { "ContinueTestPipeline" => "failed", "IgnoreChainPipeline" => "skipped", "NotificationPipeline" => "skipped" },
75
+ GoodPipeline::PipelineRecord.pluck(:type, :status).to_h
76
+ )
77
+ end
78
+
49
79
  # --- Fan-out ---
50
80
 
51
81
  def test_fan_out_both_start_after_upstream_succeeds
@@ -118,6 +148,41 @@ class TestPipelineChaining < ActiveSupport::TestCase
118
148
  assert_equal "skipped", archive.status
119
149
  end
120
150
 
151
+ def test_ignored_failure_waits_for_the_other_upstream_to_finish
152
+ ignored = create_pipeline(status: "running", on_failure_strategy: "ignore")
153
+ ignored_step = build_step(ignored, key: "optional", coordination_status: "enqueued")
154
+ waiting = create_pipeline(status: "running", on_failure_strategy: "halt")
155
+ waiting_step = build_step(waiting, key: "required", coordination_status: "enqueued")
156
+ downstream = GoodPipeline::Chain.new([ignored, waiting]).then(NotificationPipeline)
157
+
158
+ GoodPipeline::Coordinator.complete_step(ignored_step.id, succeeded: false)
159
+
160
+ assert_equal "pending", downstream.reload.status
161
+
162
+ GoodPipeline::Coordinator.complete_step(waiting_step.id, succeeded: true)
163
+
164
+ assert_equal "running", downstream.reload.status
165
+ end
166
+
167
+ def test_fan_in_requires_every_failure_to_be_ignored
168
+ ignored = create_pipeline(status: "failed", on_failure_strategy: "ignore")
169
+ blocking = create_pipeline(status: "failed", on_failure_strategy: "continue")
170
+
171
+ downstream = GoodPipeline::Chain.new([ignored, blocking]).then(NotificationPipeline)
172
+
173
+ assert_equal "skipped", downstream.reload.status
174
+ end
175
+
176
+ def test_late_chain_registration_accepts_an_ignored_failure
177
+ upstream = IgnoreChainPipeline.run
178
+ run_pipeline_to_completion(upstream)
179
+
180
+ downstream = upstream.then(NotificationPipeline)
181
+ run_pipeline_to_completion(downstream)
182
+
183
+ assert_equal "succeeded", downstream.reload.status
184
+ end
185
+
121
186
  # --- Fan-out then fan-in ---
122
187
 
123
188
  def test_fan_out_then_fan_in
@@ -159,7 +224,7 @@ class TestPipelineChaining < ActiveSupport::TestCase
159
224
  end
160
225
 
161
226
  def test_canceling_pending_upstream_automatically_skips_downstream
162
- upstream = create_pipeline(type: "TestPipeline", status: "pending", on_failure_strategy: "halt")
227
+ upstream = create_pipeline(type: "TestPipeline", status: "pending", on_failure_strategy: "ignore")
163
228
  upstream_step = build_step(upstream, key: "work")
164
229
  downstream = create_pipeline(type: "NotificationPipeline", status: "pending", on_failure_strategy: "halt")
165
230
  GoodPipeline::ChainRecord.create!(upstream_pipeline: upstream, downstream_pipeline: downstream)
@@ -172,7 +237,7 @@ class TestPipelineChaining < ActiveSupport::TestCase
172
237
  end
173
238
 
174
239
  def test_canceling_running_upstream_waits_for_drain_before_skipping_downstream
175
- upstream = create_pipeline(type: "TestPipeline", status: "running", on_failure_strategy: "halt")
240
+ upstream = create_pipeline(type: "TestPipeline", status: "running", on_failure_strategy: "ignore")
176
241
  upstream_step = build_step(upstream, key: "work")
177
242
  upstream_step.update_columns(coordination_status: "enqueued")
178
243
  downstream = create_pipeline(type: "NotificationPipeline", status: "pending", on_failure_strategy: "halt")
@@ -1,6 +1,6 @@
1
1
  # Failure Strategies
2
2
 
3
- Three failure strategies control what happens when a step fails. Set one at the pipeline level, then override per step if needed.
3
+ Three failure strategies control what happens when a step fails and whether a failed pipeline allows its chain to continue. Set one at the pipeline level, then override per step if needed.
4
4
 
5
5
  ## Pipeline-level strategy
6
6
 
@@ -140,6 +140,10 @@ A barrier depends on every step in its preceding phase, so the same rules apply
140
140
 
141
141
  Whenever eligible work remains, every new terminal step releases its outgoing dependency counter exactly once. This includes enqueue-time configuration failures and steps skipped by partial halt propagation, preventing a barrier or ordinary fan-in from remaining pending after all upstreams are terminal.
142
142
 
143
+ ## Pipeline chains
144
+
145
+ Pipeline chains use each upstream pipeline's stored `failure_strategy`. Step-level overrides do not change the chain policy. See [Pipeline Chaining](./pipeline-chaining.md#unsuccessful-upstream-propagation) for how failures affect dependent pipelines.
146
+
143
147
  ## Early termination with success
144
148
 
145
149
  Sometimes a job determines there is nothing to do — the account is deactivated, the resource was already processed, etc. Call `halt_pipeline!` to stop the pipeline early and mark it as `succeeded`:
data/docs/introduction.md CHANGED
@@ -54,7 +54,7 @@ Choose Geneva Drive when your workflow is inherently sequential and you need pau
54
54
 
55
55
  ## Requirements
56
56
 
57
- - Ruby >= 3.2
57
+ - Ruby >= 4.0
58
58
  - Rails >= 7.2
59
59
  - PostgreSQL
60
60
  - GoodJob >= 4.14 with `preserve_job_records = true`
data/docs/monitoring.md CHANGED
@@ -45,7 +45,7 @@ step.duration # => 12.34 (Float seconds, from GoodJob record)
45
45
  | `succeeded` | All steps terminal, none failed |
46
46
  | `failed` | One or more steps failed; `:continue` or `:ignore` strategy was used |
47
47
  | `halted` | `:halt` strategy was applied — `halt_triggered` is `true` |
48
- | `skipped` | Skipped because an upstream pipeline in a chain failed |
48
+ | `skipped` | An upstream pipeline failed without `:ignore`, halted, was canceled, or was skipped |
49
49
  | `canceled` | Cancellation completed, or a pending pipeline was canceled before it started — terminal |
50
50
 
51
51
  ## Step statuses
@@ -1,6 +1,8 @@
1
1
  # Pipeline Chaining
2
2
 
3
- Pipelines can be wired together into pipeline-level DAGs. A downstream pipeline starts only after all its upstream pipelines succeed.
3
+ Pipelines can be wired together into pipeline-level DAGs. A downstream pipeline starts after every upstream has succeeded or failed with `failure_strategy :ignore`.
4
+
5
+ The examples below use the default `:halt` strategy unless another strategy is shown.
4
6
 
5
7
  ## Serial chain
6
8
 
@@ -88,9 +90,9 @@ Pipeline chaining is a first-class primitive — upstream/downstream relationshi
88
90
  1. Creates downstream pipeline records with `status: pending` — params are stored immediately at chain registration time
89
91
  2. Creates `good_pipeline_chains` rows linking upstream to downstream pipeline IDs
90
92
  3. If any upstream has already reached a terminal state, immediately triggers chain propagation so the downstream is started or skipped
91
- 4. After any upstream pipeline reaches a terminal state, the chain coordinator checks if all upstreams for each downstream have succeeded
92
- 5. If all upstreams succeeded, the downstream pipeline starts (root steps are enqueued)
93
- 6. If any upstream fails, halts, is canceled, or is skipped, the downstream pipeline is set to `skipped`
93
+ 4. After any upstream pipeline reaches a terminal state, the chain coordinator checks each upstream's status and stored failure strategy
94
+ 5. If every upstream succeeded or failed with `:ignore`, the downstream pipeline starts (root steps are enqueued)
95
+ 6. If any terminal upstream does not satisfy its dependency, the downstream pipeline is set to `skipped`
94
96
 
95
97
  The chain coordinator uses a blocking `FOR UPDATE` row lock. A contending propagation waits, then rechecks the downstream pipeline while locked, so a fan-in wake-up cannot be silently dropped. This also makes `.then` safe to call after an upstream has already completed.
96
98
 
@@ -98,14 +100,16 @@ Individual chain transitions are atomic and propagation is idempotent, but deliv
98
100
 
99
101
  ## Unsuccessful upstream propagation
100
102
 
101
- If any upstream pipeline in a chain fails, halts, is canceled, or is skipped:
103
+ A failed upstream using `failure_strategy :ignore` allows the chain to continue after it finishes. Its status remains `failed`, and failure callbacks still run. The pipeline-level strategy controls chain dependencies; a step-level `on_failure:` override only controls that step's outgoing edges inside its pipeline.
104
+
105
+ If any upstream pipeline fails with `:continue`, halts, is canceled, or is skipped:
102
106
 
103
107
  - The downstream pipeline transitions to `skipped`
104
108
  - Any further downstream pipelines are also recursively `skipped`
105
109
  - `on_complete` callbacks fire on skipped pipelines, but `on_failure` does **not** — being skipped is not considered a failure
106
110
 
107
111
  ```
108
- A (failed) → B (skipped) → C (skipped) → D (skipped)
112
+ A (failed, :continue) → B (skipped) → C (skipped) → D (skipped)
109
113
  ```
110
114
 
111
115
  A `canceling` upstream is still nonterminal, so its downstream pipelines remain pending while already-enqueued work drains. Once the upstream reaches `canceled`, normal chain propagation skips the pending downstream graph.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- good_pipeline (0.5.0)
4
+ good_pipeline (0.6.0)
5
5
  activerecord (>= 7.2)
6
6
  good_job (>= 4.14)
7
7
  railties (>= 7.2)
@@ -336,7 +336,7 @@ CHECKSUMS
336
336
  fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18
337
337
  globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
338
338
  good_job (4.19.2) sha256=73f4ea0595a29d28b9d9c0bf23361cc7434542b4d286f59b803819110c26e4b0
339
- good_pipeline (0.5.0)
339
+ good_pipeline (0.6.0)
340
340
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
341
341
  io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
342
342
  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- good_pipeline (0.5.0)
4
+ good_pipeline (0.6.0)
5
5
  activerecord (>= 7.2)
6
6
  good_job (>= 4.14)
7
7
  railties (>= 7.2)
@@ -334,7 +334,7 @@ CHECKSUMS
334
334
  fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18
335
335
  globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
336
336
  good_job (4.19.2) sha256=73f4ea0595a29d28b9d9c0bf23361cc7434542b4d286f59b803819110c26e4b0
337
- good_pipeline (0.5.0)
337
+ good_pipeline (0.6.0)
338
338
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
339
339
  io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
340
340
  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- good_pipeline (0.5.0)
4
+ good_pipeline (0.6.0)
5
5
  activerecord (>= 7.2)
6
6
  good_job (>= 4.14)
7
7
  railties (>= 7.2)
@@ -338,7 +338,7 @@ CHECKSUMS
338
338
  fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18
339
339
  globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
340
340
  good_job (4.19.2) sha256=73f4ea0595a29d28b9d9c0bf23361cc7434542b4d286f59b803819110c26e4b0
341
- good_pipeline (0.5.0)
341
+ good_pipeline (0.6.0)
342
342
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
343
343
  io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
344
344
  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
@@ -22,7 +22,7 @@ module GoodPipeline
22
22
  locked.transition_to!(:skipped)
23
23
  Coordinator.dispatch_callbacks_once(locked, :skipped)
24
24
  skipped_downstream_ids = locked.downstream_pipelines.pluck(:id)
25
- elsif all_upstreams_succeeded?(locked)
25
+ elsif all_upstreams_satisfied?(locked)
26
26
  start_pipeline(locked)
27
27
  end
28
28
  end
@@ -32,12 +32,16 @@ module GoodPipeline
32
32
 
33
33
  def should_skip_downstream?(pipeline)
34
34
  pipeline.upstream_pipelines.any? do |upstream|
35
- upstream.failed? || upstream.halted? || upstream.skipped? || upstream.canceled?
35
+ upstream.terminal? && !upstream_satisfied?(upstream)
36
36
  end
37
37
  end
38
38
 
39
- def all_upstreams_succeeded?(pipeline)
40
- pipeline.upstream_pipelines.all?(&:succeeded?)
39
+ def upstream_satisfied?(upstream)
40
+ upstream.succeeded? || (upstream.failed? && upstream.ignore?)
41
+ end
42
+
43
+ def all_upstreams_satisfied?(pipeline)
44
+ pipeline.upstream_pipelines.all? { |upstream| upstream_satisfied?(upstream) }
41
45
  end
42
46
 
43
47
  def start_pipeline(pipeline_record)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GoodPipeline
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/mise.toml CHANGED
@@ -1,5 +1,5 @@
1
1
  [tools]
2
- ruby = "3.4.7"
2
+ ruby = "4.0.9"
3
3
 
4
4
  [tasks."docker:start"]
5
5
  description = "Start Docker services using docker-compose"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_pipeline
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ali Hamdi Ali Fadel
@@ -248,14 +248,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
248
248
  requirements:
249
249
  - - ">="
250
250
  - !ruby/object:Gem::Version
251
- version: 3.2.0
251
+ version: 4.0.0
252
252
  required_rubygems_version: !ruby/object:Gem::Requirement
253
253
  requirements:
254
254
  - - ">="
255
255
  - !ruby/object:Gem::Version
256
256
  version: '0'
257
257
  requirements: []
258
- rubygems_version: 3.6.9
258
+ rubygems_version: 4.0.20
259
259
  specification_version: 4
260
260
  summary: DAG-based job pipeline orchestration for Rails, built on GoodJob
261
261
  test_files: []