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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 350a7e051f704db8ee906a90bb8f641f1373be815aceb9adccc8cd17b2d38640
4
- data.tar.gz: f0cebfb8f77d35e043df87e71e5698b4e6ec08a468d95702a5a3a489eb1debf9
3
+ metadata.gz: e9df8b5fbd57895f53adf1d3c5804ca9bd64ca792e880c2d4941957e4b8ca368
4
+ data.tar.gz: c4e2a7c4edbe27a0e40e7ff62a061f5ade53c44a74c0b824ce21bdf019cb8781
5
5
  SHA512:
6
- metadata.gz: 682725126bc0643cd8ec88d249ef7e93b760d646a4aac30cbb693c1bde6d1c0b3d30494623eb5ffed8cc2845f62dc3642501648bf65cbf3adc43577f13734270
7
- data.tar.gz: 5e2aae2df9e5b8997b85a4aff15d0989fa24f27b6e607332d82421c5e5b33d0a4171e455ddc04eab71f790657fe9f71d59b412b7ffa054b7f15c60148b1a1536
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" }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HaltExecutionJob < ApplicationJob
4
+ def perform(**)
5
+ halt_pipeline!
6
+ end
7
+ end
@@ -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
@@ -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
@@ -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]
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ module Haltable
5
+ def halt_pipeline!
6
+ step = GoodPipeline::StepRecord.find_by(good_job_id: provider_job_id)
7
+ step&.update_columns(halt_requested: true, updated_at: Time.current)
8
+ end
9
+ end
10
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GoodPipeline
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
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.0
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