good_pipeline 0.3.0 → 0.3.1
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/CHANGELOG.md +9 -0
- data/app/helpers/good_pipeline/mermaid_diagram_builder.rb +1 -0
- data/app/models/good_pipeline/step_record.rb +5 -4
- data/demo/app/jobs/halt_execution_job.rb +7 -0
- data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +2 -0
- data/demo/test/integration/test_halt_execution.rb +113 -0
- data/docs/failure-strategies.md +35 -0
- data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +2 -0
- data/lib/good_pipeline/coordinator.rb +21 -1
- data/lib/good_pipeline/engine.rb +6 -0
- data/lib/good_pipeline/haltable.rb +10 -0
- data/lib/good_pipeline/version.rb +1 -1
- data/lib/good_pipeline.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9df8b5fbd57895f53adf1d3c5804ca9bd64ca792e880c2d4941957e4b8ca368
|
|
4
|
+
data.tar.gz: c4e2a7c4edbe27a0e40e7ff62a061f5ade53c44a74c0b824ce21bdf019cb8781
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3c6e6940034efbc8679ede33b352022deb64a36b7a16a9dd3f02fd6dc32a837caf545407541bd0ca629f690d38cfa60342e6aa756e53ea321fb754eebbd772c
|
|
7
|
+
data.tar.gz: 878566f6d9ddc7b9ce6b4201f5bd241b21f2758f5475e3fa60e6c3fd629651bff06ff973af9f3f29ac4624bee58e78dc0f9628e8a1cdb503836e923a09e3944e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.1] - 2026-03-26
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`halt_pipeline!`** — call from any job to stop the pipeline early with a `succeeded` status. The halting step is marked `halted`, remaining pending steps are `skipped`, and the `on_success` callback fires. The GoodJob record completes as succeeded (no error, no discard). Available in all jobs via `GoodPipeline::Haltable`, included automatically by the Engine.
|
|
8
|
+
- **`halted` coordination status** — new terminal step status for steps that called `halt_pipeline!`. Treated as satisfied for downstream dependency resolution.
|
|
9
|
+
- **`halt_requested` column** — boolean column on steps table, set by `halt_pipeline!` and checked by the coordinator on step completion.
|
|
10
|
+
- **`good_job_id` index** — partial unique index on `good_job_id` for fast step lookup from within jobs.
|
|
11
|
+
|
|
3
12
|
## [0.3.0] - 2026-03-25
|
|
4
13
|
|
|
5
14
|
### Performance
|
|
@@ -15,6 +15,7 @@ module GoodPipeline
|
|
|
15
15
|
" classDef failed fill:#f44336,color:#fff",
|
|
16
16
|
" classDef skipped fill:#bdbdbd,color:#333",
|
|
17
17
|
" classDef skipped_by_branch fill:#bdbdbd,color:#333",
|
|
18
|
+
" classDef halted fill:#8bc34a,color:#fff",
|
|
18
19
|
" classDef branch fill:#ff9800,color:#fff,stroke:#f57c00",
|
|
19
20
|
" classDef terminal fill:#1a1a2e,color:#fff,stroke:#1a1a2e"
|
|
20
21
|
].freeze
|
|
@@ -7,11 +7,11 @@ module GoodPipeline
|
|
|
7
7
|
class StepRecord < ActiveRecord::Base
|
|
8
8
|
self.table_name = "good_pipeline_steps"
|
|
9
9
|
|
|
10
|
-
TERMINAL_COORDINATION_STATUSES = %w[succeeded failed skipped skipped_by_branch].freeze
|
|
10
|
+
TERMINAL_COORDINATION_STATUSES = %w[succeeded failed skipped skipped_by_branch halted].freeze
|
|
11
11
|
|
|
12
12
|
VALID_COORDINATION_TRANSITIONS = {
|
|
13
|
-
"pending" => %w[enqueued skipped skipped_by_branch succeeded failed],
|
|
14
|
-
"enqueued" => %w[succeeded failed]
|
|
13
|
+
"pending" => %w[enqueued skipped skipped_by_branch succeeded failed halted],
|
|
14
|
+
"enqueued" => %w[succeeded failed halted]
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
17
|
enum :coordination_status, {
|
|
@@ -20,7 +20,8 @@ module GoodPipeline
|
|
|
20
20
|
succeeded: "succeeded",
|
|
21
21
|
failed: "failed",
|
|
22
22
|
skipped: "skipped",
|
|
23
|
-
skipped_by_branch: "skipped_by_branch"
|
|
23
|
+
skipped_by_branch: "skipped_by_branch",
|
|
24
|
+
halted: "halted"
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
enum :on_failure_strategy, { halt: "halt", continue: "continue", ignore: "ignore" }
|
|
@@ -31,6 +31,7 @@ class CreateGoodPipelineTables < ActiveRecord::Migration[8.1]
|
|
|
31
31
|
t.uuid :good_job_batch_id
|
|
32
32
|
t.uuid :good_job_id
|
|
33
33
|
t.integer :pending_upstream_count, null: false, default: 0
|
|
34
|
+
t.boolean :halt_requested, null: false, default: false
|
|
34
35
|
t.integer :attempts
|
|
35
36
|
t.string :error_class
|
|
36
37
|
t.text :error_message
|
|
@@ -40,6 +41,7 @@ class CreateGoodPipelineTables < ActiveRecord::Migration[8.1]
|
|
|
40
41
|
|
|
41
42
|
add_index :good_pipeline_steps, %i[pipeline_id key], unique: true
|
|
42
43
|
add_index :good_pipeline_steps, :coordination_status
|
|
44
|
+
add_index :good_pipeline_steps, :good_job_id, unique: true, where: "good_job_id IS NOT NULL"
|
|
43
45
|
|
|
44
46
|
create_table :good_pipeline_dependencies do |t|
|
|
45
47
|
t.references :pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestHaltExecution < ActiveSupport::TestCase
|
|
6
|
+
def run_pipeline_to_completion(pipeline_record, timeout: 15)
|
|
7
|
+
deadline = Time.current + timeout
|
|
8
|
+
loop do
|
|
9
|
+
perform_enqueued_jobs_inline
|
|
10
|
+
pipeline_record.reload
|
|
11
|
+
return pipeline_record if pipeline_record.terminal?
|
|
12
|
+
|
|
13
|
+
raise "Pipeline did not reach terminal state within #{timeout}s (status: #{pipeline_record.status})" if Time.current > deadline
|
|
14
|
+
|
|
15
|
+
sleep 0.05
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_halt_pipeline_marks_step_halted
|
|
20
|
+
pipeline_class = Class.new(GoodPipeline::Pipeline) do
|
|
21
|
+
failure_strategy :halt
|
|
22
|
+
define_method(:configure) do |**|
|
|
23
|
+
run :halt_step, HaltExecutionJob
|
|
24
|
+
run :after_step, DownloadJob, after: :halt_step
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
Object.const_set(:HaltSucceededPipeline, pipeline_class) unless defined?(::HaltSucceededPipeline)
|
|
28
|
+
|
|
29
|
+
chain = HaltSucceededPipeline.run
|
|
30
|
+
result = run_pipeline_to_completion(chain)
|
|
31
|
+
|
|
32
|
+
halt_step = result.steps.find_by(key: "halt_step")
|
|
33
|
+
assert_equal "halted", halt_step.coordination_status
|
|
34
|
+
assert halt_step.halt_requested?, "halt_requested should be true"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def test_halt_pipeline_skips_remaining_steps
|
|
38
|
+
pipeline_class = Class.new(GoodPipeline::Pipeline) do
|
|
39
|
+
failure_strategy :halt
|
|
40
|
+
define_method(:configure) do |**|
|
|
41
|
+
run :halt_step, HaltExecutionJob
|
|
42
|
+
run :after_step, DownloadJob, after: :halt_step
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
Object.const_set(:HaltSkipsPipeline, pipeline_class) unless defined?(::HaltSkipsPipeline)
|
|
46
|
+
|
|
47
|
+
chain = HaltSkipsPipeline.run
|
|
48
|
+
result = run_pipeline_to_completion(chain)
|
|
49
|
+
|
|
50
|
+
after_step = result.steps.find_by(key: "after_step")
|
|
51
|
+
assert_equal "skipped", after_step.coordination_status
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_halt_pipeline_pipeline_succeeds
|
|
55
|
+
pipeline_class = Class.new(GoodPipeline::Pipeline) do
|
|
56
|
+
failure_strategy :halt
|
|
57
|
+
define_method(:configure) do |**|
|
|
58
|
+
run :halt_step, HaltExecutionJob
|
|
59
|
+
run :after_step, DownloadJob, after: :halt_step
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
Object.const_set(:HaltSucceedsPipeline, pipeline_class) unless defined?(::HaltSucceedsPipeline)
|
|
63
|
+
|
|
64
|
+
chain = HaltSucceedsPipeline.run
|
|
65
|
+
result = run_pipeline_to_completion(chain)
|
|
66
|
+
|
|
67
|
+
assert_equal "succeeded", result.status
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_halt_pipeline_job_succeeds_in_good_job
|
|
71
|
+
pipeline_class = Class.new(GoodPipeline::Pipeline) do
|
|
72
|
+
failure_strategy :halt
|
|
73
|
+
define_method(:configure) do |**|
|
|
74
|
+
run :halt_step, HaltExecutionJob
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
Object.const_set(:HaltJobSucceedsPipeline, pipeline_class) unless defined?(::HaltJobSucceedsPipeline)
|
|
78
|
+
|
|
79
|
+
chain = HaltJobSucceedsPipeline.run
|
|
80
|
+
run_pipeline_to_completion(chain)
|
|
81
|
+
|
|
82
|
+
halt_step = chain.steps.find_by(key: "halt_step")
|
|
83
|
+
good_job = GoodJob::Job.find(halt_step.good_job_id)
|
|
84
|
+
|
|
85
|
+
assert_equal 1, good_job.executions_count
|
|
86
|
+
assert_nil good_job.error, "GoodJob record should have no error"
|
|
87
|
+
assert_not_nil good_job.finished_at
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_halt_pipeline_with_parallel_steps
|
|
91
|
+
pipeline_class = Class.new(GoodPipeline::Pipeline) do
|
|
92
|
+
failure_strategy :continue
|
|
93
|
+
define_method(:configure) do |**|
|
|
94
|
+
run :halt_step, HaltExecutionJob
|
|
95
|
+
run :normal_step, DownloadJob
|
|
96
|
+
run :after_both, CleanupJob, after: %i[halt_step normal_step]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
Object.const_set(:HaltParallelPipeline, pipeline_class) unless defined?(::HaltParallelPipeline)
|
|
100
|
+
|
|
101
|
+
chain = HaltParallelPipeline.run
|
|
102
|
+
result = run_pipeline_to_completion(chain)
|
|
103
|
+
|
|
104
|
+
halt_step = result.steps.find_by(key: "halt_step")
|
|
105
|
+
normal_step = result.steps.find_by(key: "normal_step")
|
|
106
|
+
after_both = result.steps.find_by(key: "after_both")
|
|
107
|
+
|
|
108
|
+
assert_equal "halted", halt_step.coordination_status
|
|
109
|
+
assert_equal "succeeded", normal_step.coordination_status
|
|
110
|
+
assert_equal "skipped", after_both.coordination_status
|
|
111
|
+
assert_equal "succeeded", result.status
|
|
112
|
+
end
|
|
113
|
+
end
|
data/docs/failure-strategies.md
CHANGED
|
@@ -127,6 +127,41 @@ A downstream step is eligible for enqueue when **all** of its incoming edges are
|
|
|
127
127
|
|
|
128
128
|
A downstream step is marked `skipped` when it's still `pending` and at least one incoming edge is **permanently unsatisfied** — the upstream is terminal, cannot satisfy the edge, and no future event can change that.
|
|
129
129
|
|
|
130
|
+
## Early termination with success
|
|
131
|
+
|
|
132
|
+
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`:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
class FetchDataJob < ApplicationJob
|
|
136
|
+
def perform(account_id:)
|
|
137
|
+
account = Account.find(account_id)
|
|
138
|
+
return halt_pipeline! if account.deactivated?
|
|
139
|
+
|
|
140
|
+
# ... normal work
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The behavior:
|
|
146
|
+
|
|
147
|
+
| Aspect | Value |
|
|
148
|
+
|---|---|
|
|
149
|
+
| Halting step status | `halted` |
|
|
150
|
+
| Remaining pending steps | `skipped` |
|
|
151
|
+
| Pipeline status | `succeeded` |
|
|
152
|
+
| Callback triggered | `on_success` |
|
|
153
|
+
| GoodJob record | Succeeded (no error, no discard) |
|
|
154
|
+
|
|
155
|
+
No configuration or module includes are required. The Engine includes `GoodPipeline::Haltable` into `ActiveJob::Base` at boot, so `halt_pipeline!` is available in any job. For non-pipeline jobs, it's a no-op.
|
|
156
|
+
|
|
157
|
+
::: tip Return early
|
|
158
|
+
Remember to use `return halt_pipeline!` — without `return`, the job continues executing after the call.
|
|
159
|
+
:::
|
|
160
|
+
|
|
161
|
+
::: warning Parallel steps
|
|
162
|
+
If another step is already running when `halt_pipeline!` is called, that step continues to completion. Only `pending` steps are skipped. If the running step fails, the pipeline will derive to `failed`, not `succeeded`.
|
|
163
|
+
:::
|
|
164
|
+
|
|
130
165
|
## Failure resolution table
|
|
131
166
|
|
|
132
167
|
| Pipeline strategy | Step override | Effect when step fails |
|
|
@@ -29,6 +29,7 @@ class CreateGoodPipelineTables < ActiveRecord::Migration[<%= ActiveRecord::Migra
|
|
|
29
29
|
t.uuid :good_job_batch_id
|
|
30
30
|
t.uuid :good_job_id
|
|
31
31
|
t.integer :pending_upstream_count, null: false, default: 0
|
|
32
|
+
t.boolean :halt_requested, null: false, default: false
|
|
32
33
|
t.integer :attempts
|
|
33
34
|
t.string :error_class
|
|
34
35
|
t.text :error_message
|
|
@@ -38,6 +39,7 @@ class CreateGoodPipelineTables < ActiveRecord::Migration[<%= ActiveRecord::Migra
|
|
|
38
39
|
|
|
39
40
|
add_index :good_pipeline_steps, %i[pipeline_id key], unique: true
|
|
40
41
|
add_index :good_pipeline_steps, :coordination_status
|
|
42
|
+
add_index :good_pipeline_steps, :good_job_id, unique: true, where: "good_job_id IS NOT NULL"
|
|
41
43
|
|
|
42
44
|
create_table :good_pipeline_dependencies do |t|
|
|
43
45
|
t.references :pipeline, null: false, foreign_key: { to_table: :good_pipeline_pipelines }, type: :uuid
|
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
module GoodPipeline
|
|
4
4
|
class Coordinator # rubocop:disable Metrics/ClassLength
|
|
5
5
|
class << self
|
|
6
|
-
def complete_step(step, succeeded:)
|
|
6
|
+
def complete_step(step, succeeded:) # rubocop:disable Metrics/MethodLength
|
|
7
7
|
return if step.terminal_coordination_status?
|
|
8
8
|
|
|
9
|
+
if succeeded && step.halt_requested?
|
|
10
|
+
handle_halt_execution(step)
|
|
11
|
+
return
|
|
12
|
+
end
|
|
13
|
+
|
|
9
14
|
record_step_outcome(step, succeeded)
|
|
10
15
|
propagate_halt(step) if !succeeded && step.pipeline.halt?
|
|
11
16
|
return if unblock_downstream_steps(step)
|
|
@@ -74,6 +79,19 @@ module GoodPipeline
|
|
|
74
79
|
|
|
75
80
|
private
|
|
76
81
|
|
|
82
|
+
def handle_halt_execution(step)
|
|
83
|
+
step.transition_coordination_status_to!(:halted)
|
|
84
|
+
step.pipeline.steps.pending.update_all(coordination_status: "skipped")
|
|
85
|
+
|
|
86
|
+
pipeline = load_pipeline_with_active_check(step.pipeline_id)
|
|
87
|
+
|
|
88
|
+
recompute_pipeline_status(
|
|
89
|
+
pipeline,
|
|
90
|
+
has_active_steps: pipeline["has_active_steps"],
|
|
91
|
+
has_downstream_chains: pipeline["has_downstream_chains"]
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
77
95
|
def record_step_outcome(step, succeeded)
|
|
78
96
|
if succeeded
|
|
79
97
|
step.transition_coordination_status_to!(:succeeded)
|
|
@@ -173,6 +191,7 @@ module GoodPipeline
|
|
|
173
191
|
def permanently_unsatisfied?(upstream)
|
|
174
192
|
upstream.terminal_coordination_status? &&
|
|
175
193
|
!upstream.succeeded? &&
|
|
194
|
+
!upstream.halted? &&
|
|
176
195
|
!upstream.skipped_by_branch? &&
|
|
177
196
|
effective_failure_strategy(upstream) != :ignore
|
|
178
197
|
end
|
|
@@ -186,6 +205,7 @@ module GoodPipeline
|
|
|
186
205
|
def all_upstreams_satisfied?(step)
|
|
187
206
|
step.upstream_steps.all? do |upstream|
|
|
188
207
|
upstream.succeeded? ||
|
|
208
|
+
upstream.halted? ||
|
|
189
209
|
upstream.skipped_by_branch? ||
|
|
190
210
|
(upstream.failed? && effective_failure_strategy(upstream) == :ignore)
|
|
191
211
|
end
|
data/lib/good_pipeline/engine.rb
CHANGED
|
@@ -13,6 +13,12 @@ module GoodPipeline
|
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
initializer "good_pipeline.haltable" do
|
|
17
|
+
ActiveSupport.on_load(:active_job) do
|
|
18
|
+
include GoodPipeline::Haltable
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
initializer "good_pipeline.cleanup_hook" do
|
|
17
23
|
ActiveSupport::Notifications.subscribe("cleanup_preserved_jobs.good_job") do |event|
|
|
18
24
|
timestamp = event.payload[:timestamp]
|
data/lib/good_pipeline.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "good_pipeline/branch_resolver"
|
|
|
13
13
|
require_relative "good_pipeline/coordinator"
|
|
14
14
|
require_relative "good_pipeline/chain_coordinator"
|
|
15
15
|
require_relative "good_pipeline/runner"
|
|
16
|
+
require_relative "good_pipeline/haltable"
|
|
16
17
|
require_relative "good_pipeline/chain"
|
|
17
18
|
require_relative "good_pipeline/engine" if defined?(Rails::Engine)
|
|
18
19
|
|
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.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ali Hamdi Ali Fadel
|
|
@@ -93,6 +93,7 @@ files:
|
|
|
93
93
|
- demo/app/jobs/cleanup_job.rb
|
|
94
94
|
- demo/app/jobs/download_job.rb
|
|
95
95
|
- demo/app/jobs/failing_job.rb
|
|
96
|
+
- demo/app/jobs/halt_execution_job.rb
|
|
96
97
|
- demo/app/jobs/publish_job.rb
|
|
97
98
|
- demo/app/jobs/retryable_job.rb
|
|
98
99
|
- demo/app/jobs/thumbnail_job.rb
|
|
@@ -135,6 +136,7 @@ files:
|
|
|
135
136
|
- demo/test/integration/test_concurrent_fan_in.rb
|
|
136
137
|
- demo/test/integration/test_end_to_end.rb
|
|
137
138
|
- demo/test/integration/test_enqueue_atomicity.rb
|
|
139
|
+
- demo/test/integration/test_halt_execution.rb
|
|
138
140
|
- demo/test/integration/test_halt_ignore_chain.rb
|
|
139
141
|
- demo/test/integration/test_ignore_transitive_exemption.rb
|
|
140
142
|
- demo/test/integration/test_late_chain_registration.rb
|
|
@@ -184,6 +186,7 @@ files:
|
|
|
184
186
|
- lib/good_pipeline/errors.rb
|
|
185
187
|
- lib/good_pipeline/failure_metadata.rb
|
|
186
188
|
- lib/good_pipeline/graph_validator.rb
|
|
189
|
+
- lib/good_pipeline/haltable.rb
|
|
187
190
|
- lib/good_pipeline/pipeline.rb
|
|
188
191
|
- lib/good_pipeline/runner.rb
|
|
189
192
|
- lib/good_pipeline/step_definition.rb
|