good_pipeline 0.3.0 → 0.4.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.
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestHaltExecution < ActiveSupport::TestCase
6
+ def test_halt_pipeline_marks_step_halted
7
+ pipeline_class = Class.new(GoodPipeline::Pipeline) do
8
+ failure_strategy :halt
9
+ define_method(:configure) do |**|
10
+ run :halt_step, HaltExecutionJob
11
+ run :after_step, DownloadJob, after: :halt_step
12
+ end
13
+ end
14
+ Object.const_set(:HaltSucceededPipeline, pipeline_class) unless defined?(::HaltSucceededPipeline)
15
+
16
+ chain = HaltSucceededPipeline.run
17
+ result = run_pipeline_to_completion(chain)
18
+
19
+ halt_step = result.steps.find_by(key: "halt_step")
20
+ assert_equal "halted", halt_step.coordination_status
21
+ assert halt_step.halt_requested?, "halt_requested should be true"
22
+ end
23
+
24
+ def test_halt_pipeline_skips_remaining_steps
25
+ pipeline_class = Class.new(GoodPipeline::Pipeline) do
26
+ failure_strategy :halt
27
+ define_method(:configure) do |**|
28
+ run :halt_step, HaltExecutionJob
29
+ run :after_step, DownloadJob, after: :halt_step
30
+ end
31
+ end
32
+ Object.const_set(:HaltSkipsPipeline, pipeline_class) unless defined?(::HaltSkipsPipeline)
33
+
34
+ chain = HaltSkipsPipeline.run
35
+ result = run_pipeline_to_completion(chain)
36
+
37
+ after_step = result.steps.find_by(key: "after_step")
38
+ assert_equal "skipped", after_step.coordination_status
39
+ end
40
+
41
+ def test_halt_pipeline_pipeline_succeeds
42
+ pipeline_class = Class.new(GoodPipeline::Pipeline) do
43
+ failure_strategy :halt
44
+ define_method(:configure) do |**|
45
+ run :halt_step, HaltExecutionJob
46
+ run :after_step, DownloadJob, after: :halt_step
47
+ end
48
+ end
49
+ Object.const_set(:HaltSucceedsPipeline, pipeline_class) unless defined?(::HaltSucceedsPipeline)
50
+
51
+ chain = HaltSucceedsPipeline.run
52
+ result = run_pipeline_to_completion(chain)
53
+
54
+ assert_equal "succeeded", result.status
55
+ end
56
+
57
+ def test_halt_pipeline_job_succeeds_in_good_job
58
+ pipeline_class = Class.new(GoodPipeline::Pipeline) do
59
+ failure_strategy :halt
60
+ define_method(:configure) do |**|
61
+ run :halt_step, HaltExecutionJob
62
+ end
63
+ end
64
+ Object.const_set(:HaltJobSucceedsPipeline, pipeline_class) unless defined?(::HaltJobSucceedsPipeline)
65
+
66
+ chain = HaltJobSucceedsPipeline.run
67
+ run_pipeline_to_completion(chain)
68
+
69
+ halt_step = chain.steps.find_by(key: "halt_step")
70
+ good_job = GoodJob::Job.find(halt_step.good_job_id)
71
+
72
+ assert_equal 1, good_job.executions_count
73
+ assert_nil good_job.error, "GoodJob record should have no error"
74
+ assert_not_nil good_job.finished_at
75
+ end
76
+
77
+ def test_halt_pipeline_with_parallel_steps
78
+ pipeline_class = Class.new(GoodPipeline::Pipeline) do
79
+ failure_strategy :continue
80
+ define_method(:configure) do |**|
81
+ run :halt_step, HaltExecutionJob
82
+ run :normal_step, DownloadJob
83
+ run :after_both, CleanupJob, after: %i[halt_step normal_step]
84
+ end
85
+ end
86
+ Object.const_set(:HaltParallelPipeline, pipeline_class) unless defined?(::HaltParallelPipeline)
87
+
88
+ chain = HaltParallelPipeline.run
89
+ result = run_pipeline_to_completion(chain)
90
+
91
+ halt_step = result.steps.find_by(key: "halt_step")
92
+ normal_step = result.steps.find_by(key: "normal_step")
93
+ after_both = result.steps.find_by(key: "after_both")
94
+
95
+ assert_equal "halted", halt_step.coordination_status
96
+ assert_equal "succeeded", normal_step.coordination_status
97
+ assert_equal "skipped", after_both.coordination_status
98
+ assert_equal "succeeded", result.status
99
+ end
100
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class TestQueueConfigurationEndToEnd < ActiveSupport::TestCase
6
+ teardown do
7
+ GoodPipeline.coordination_queue_name = nil
8
+ GoodPipeline.callback_queue_name = nil
9
+ end
10
+
11
+ def test_full_pipeline_with_custom_queues
12
+ klass = Class.new(GoodPipeline::Pipeline) do
13
+ failure_strategy :halt
14
+ coordination_queue_name "e2e_coordination"
15
+ callback_queue_name "e2e_callbacks"
16
+
17
+ define_method(:configure) do |**_kwargs|
18
+ run :step_a, DownloadJob
19
+ run :step_b, TranscodeJob
20
+ run :step_c, PublishJob, after: %i[step_a step_b]
21
+ end
22
+ end
23
+ klass.define_singleton_method(:name) { "QueueE2ETestPipeline" }
24
+ Object.const_set(:QueueE2ETestPipeline, klass) unless defined?(::QueueE2ETestPipeline)
25
+
26
+ pipeline_record = QueueE2ETestPipeline.run
27
+
28
+ # Verify step batch queue names
29
+ pipeline_record.steps.each do |step|
30
+ next unless step.good_job_batch_id
31
+
32
+ batch_record = GoodJob::BatchRecord.find(step.good_job_batch_id)
33
+
34
+ assert_equal "e2e_coordination", batch_record.callback_queue_name,
35
+ "Step #{step.key} batch should use coordination queue"
36
+ end
37
+
38
+ # Verify pipeline batch queue name
39
+ actual_record = GoodPipeline::PipelineRecord.find(pipeline_record.id)
40
+ pipeline_batch = GoodJob::BatchRecord.find(actual_record.good_job_batch_id)
41
+
42
+ assert_equal "e2e_coordination", pipeline_batch.callback_queue_name
43
+
44
+ # Run to completion and verify callback job queue
45
+ result = run_pipeline_to_completion(pipeline_record)
46
+
47
+ assert_equal "succeeded", result.status
48
+
49
+ callback_job = GoodJob::Job.where(job_class: "GoodPipeline::PipelineCallbackJob").last
50
+
51
+ assert_equal "e2e_callbacks", callback_job.queue_name
52
+ end
53
+
54
+ def test_global_config_applies_when_no_dsl
55
+ GoodPipeline.coordination_queue_name = "global_coord"
56
+ GoodPipeline.callback_queue_name = "global_cb"
57
+
58
+ klass = Class.new(GoodPipeline::Pipeline) do
59
+ failure_strategy :halt
60
+ define_method(:configure) do |**_kwargs|
61
+ run :step_a, DownloadJob
62
+ end
63
+ end
64
+ klass.define_singleton_method(:name) { "GlobalQueueTestPipeline" }
65
+ Object.const_set(:GlobalQueueTestPipeline, klass) unless defined?(::GlobalQueueTestPipeline)
66
+
67
+ pipeline_record = GlobalQueueTestPipeline.run
68
+
69
+ step = pipeline_record.steps.first
70
+ step_batch = GoodJob::BatchRecord.find(step.good_job_batch_id)
71
+
72
+ assert_equal "global_coord", step_batch.callback_queue_name
73
+
74
+ result = run_pipeline_to_completion(pipeline_record)
75
+
76
+ assert_equal "succeeded", result.status
77
+
78
+ callback_job = GoodJob::Job.where(job_class: "GoodPipeline::PipelineCallbackJob").last
79
+
80
+ assert_equal "global_cb", callback_job.queue_name
81
+ end
82
+ end
@@ -33,6 +33,21 @@ module ActiveSupport
33
33
  GoodJob.perform_inline
34
34
  end
35
35
 
36
+ def run_pipeline_to_completion(pipeline_record, timeout: 15)
37
+ deadline = Time.current + timeout
38
+ loop do
39
+ perform_enqueued_jobs_inline
40
+ pipeline_record.reload
41
+ return pipeline_record if pipeline_record.terminal?
42
+
43
+ if Time.current > deadline
44
+ raise "Pipeline did not reach terminal state within #{timeout}s (status: #{pipeline_record.status})"
45
+ end
46
+
47
+ sleep 0.05
48
+ end
49
+ end
50
+
36
51
  def wait_until(timeout: 10, interval: 0.1)
37
52
  deadline = Time.current + timeout
38
53
  loop do
data/docs/architecture.md CHANGED
@@ -180,3 +180,11 @@ This ensures a step is never prematurely marked `failed` on attempt 1 of 5.
180
180
  7. Separate atomic units per transaction boundary to minimize lock contention
181
181
  8. DAG validation runs at instantiation, before any database writes
182
182
  9. `failure_strategy` and `on_failure` are distinct concepts -- strategy vs. callback, no naming collision
183
+
184
+ ## Why these tradeoffs
185
+
186
+ GoodPipeline is intentionally GoodJob-specific and Postgres-only. This is what enables atomic enqueue transactions — step status transitions and GoodJob record inserts happen in a single database transaction, eliminating an entire class of partial-state bugs that adapter-agnostic gems must work around.
187
+
188
+ The DAG execution model (vs. strictly sequential steps) adds coordination complexity — row locks, atomic counters, fan-in race prevention — but unlocks parallel execution of independent steps. For workflows where steps have no dependency on each other, this means wall-clock time is bounded by the longest path through the graph, not the sum of all steps.
189
+
190
+ The four-table data model (pipelines, steps, dependencies, chains) is more tables than a two-table approach, but dedicated dependency and chain tables enable efficient graph queries and keep the step table free of self-referential joins.
data/docs/branching.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Conditional branching lets a pipeline take different paths at runtime based on application state. The dashboard renders branches as diamond decision nodes.
4
4
 
5
+ Runtime branching with `branch` blocks is uncommon among Ruby workflow gems — most offer only `skip_if` conditions on individual steps. GoodPipeline's branching evaluates a decision method when the branch is reached, runs the matching arm, marks non-matching arms as `skipped_by_branch`, and lets downstream steps wait on whichever arm was chosen.
6
+
5
7
  ## Defining a branch
6
8
 
7
9
  Use `branch` inside `configure` to define a decision point. The `by:` option names a method on your pipeline class that returns the arm to execute:
data/docs/callbacks.md CHANGED
@@ -46,6 +46,8 @@ Note: `on_failure` does **not** fire for `skipped` pipelines. Being skipped by a
46
46
 
47
47
  Callbacks are dispatched via `PipelineCallbackJob`, a GoodJob job enqueued after the terminal state transaction commits. A slow external call (Slack, webhooks) cannot stall the coordinator, callback execution cannot corrupt pipeline state, and callbacks get GoodJob's retry mechanism if they fail.
48
48
 
49
+ `PipelineCallbackJob` runs on the queue configured by `callback_queue_name` (default: `"good_pipeline_callbacks"`). This is separate from `coordination_queue_name` which controls the coordination jobs (`StepFinishedJob`, `PipelineReconciliationJob`), so slow callbacks don't block pipeline progression. See [Defining Pipelines](/defining-pipelines) for configuration options.
50
+
49
51
  ## Exactly-once guarantee
50
52
 
51
53
  The callback bundle (`on_complete` + one of `on_success`/`on_failure`) is dispatched as a **single unit**. A `callbacks_dispatched_at` timestamp is set atomically inside a `FOR UPDATE` locked transaction, ensuring the bundle fires exactly once even if `recompute_pipeline_status` is called from multiple code paths (coordinator or batch reconciliation).
@@ -8,6 +8,8 @@ Every pipeline is a subclass of `GoodPipeline::Pipeline` that implements `config
8
8
  class VideoProcessingPipeline < GoodPipeline::Pipeline
9
9
  description "Downloads, transcodes and publishes a video"
10
10
  failure_strategy :halt
11
+ coordination_queue_name "video_coordination"
12
+ callback_queue_name "video_callbacks"
11
13
 
12
14
  on_complete :notify
13
15
  on_success :celebrate
@@ -39,6 +41,8 @@ end
39
41
  | `on_complete` | Callback for any terminal state | `nil` |
40
42
  | `on_success` | Callback for succeeded | `nil` |
41
43
  | `on_failure` | Callback for failed or halted | `nil` |
44
+ | `coordination_queue_name` | Queue for `StepFinishedJob` and `PipelineReconciliationJob` | `"good_pipeline_coordination"` |
45
+ | `callback_queue_name` | Queue for `PipelineCallbackJob` | `"good_pipeline_callbacks"` |
42
46
 
43
47
  ## DSL verbs
44
48
 
@@ -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 |
@@ -36,6 +36,18 @@ GoodJob.preserve_job_records = true
36
36
 
37
37
  GoodPipeline will raise `GoodPipeline::ConfigurationError` at boot if this is not set.
38
38
 
39
+ ## Configure queue names (optional)
40
+
41
+ GoodPipeline routes its internal jobs to dedicated queues by default. You can override them globally:
42
+
43
+ ```ruby
44
+ # config/initializers/good_pipeline.rb
45
+ GoodPipeline.coordination_queue_name = "pipeline_coordination" # StepFinishedJob, PipelineReconciliationJob
46
+ GoodPipeline.callback_queue_name = "pipeline_callbacks" # PipelineCallbackJob
47
+ ```
48
+
49
+ Defaults are `"good_pipeline_coordination"` and `"good_pipeline_callbacks"`. Per-pipeline overrides are also available via the class DSL — see [Defining Pipelines](/defining-pipelines).
50
+
39
51
  ## Mount the dashboard (optional)
40
52
 
41
53
  ```ruby
data/docs/index.md CHANGED
@@ -17,7 +17,7 @@ features:
17
17
  - title: Postgres only
18
18
  details: All state lives in Postgres. No Redis, no external dependencies. Step transitions and job enqueues happen in a single database transaction.
19
19
  - title: DAG orchestration
20
- details: Define pipelines as directed acyclic graphs with the run and branch DSL. Steps run in parallel when possible, wait for dependencies automatically, and take different paths based on runtime decisions. Fan-out, fan-in, branching, and chaining are all there.
20
+ details: Define pipelines as directed acyclic graphs not just linear chains. Steps run in parallel when possible, wait for dependencies automatically, and take different paths based on runtime decisions. Fan-out, fan-in, branching, and chaining are all first-class.
21
21
  - title: Web dashboard
22
22
  details: A mountable Rails engine with pipeline executions, step details, DAG visualization, and a pipeline definitions catalog. No build step.
23
23
  ---
data/docs/introduction.md CHANGED
@@ -23,6 +23,24 @@ GoodJob's Batch feature fires a single `on_finish` callback when all jobs in a b
23
23
 
24
24
  GoodPipeline adds a coordination state machine, DAG validation, and atomic step transitions on top of Batch.
25
25
 
26
+ ### vs. Active Job Continuation (Rails 8.1)
27
+
28
+ Rails 8.1 ships with `ActiveJob::Continuable`, which lets a single job define sequential steps with cursor-based progress tracking. If a deploy kills the process, the job resumes from its last checkpoint instead of restarting from scratch.
29
+
30
+ This solves a different problem than GoodPipeline. Continuation makes one long-running job resilient to interruption. GoodPipeline orchestrates multiple independent jobs as a DAG with parallel execution, fan-out/fan-in, branching, and pipeline-level failure strategies.
31
+
32
+ The two are complementary: a GoodPipeline step that processes millions of records could use `Continuable` internally for checkpoint/resume, while GoodPipeline handles the higher-level orchestration around it.
33
+
34
+ ### vs. Geneva Drive
35
+
36
+ [Geneva Drive](https://github.com/julik/geneva_drive) is a durable workflow framework that executes steps strictly sequentially — one step at a time, like the mechanical gear it's named after. It works with any ActiveJob adapter (Sidekiq, Solid Queue, GoodJob) and supports PostgreSQL, MySQL, and SQLite.
37
+
38
+ Geneva Drive is a strong choice for linear, long-lived workflows with pause/resume with human-in-the-loop recovery and per-hero workflow uniqueness constraints. Its layered exception policy system is particularly sophisticated.
39
+
40
+ GoodPipeline takes a different approach: workflows are DAGs, not linear chains. Independent steps run in parallel across workers. Fan-out, fan-in, conditional branching, and pipeline chaining are first-class primitives. The tradeoff is that GoodPipeline requires GoodJob and PostgreSQL specifically, while Geneva Drive is adapter- and database-agnostic.
41
+
42
+ Choose Geneva Drive when your workflow is inherently sequential and you need pause/resume or adapter flexibility. Choose GoodPipeline when steps can run concurrently, your workflow has branching or fan-in topology, or you want a built-in dashboard with DAG visualization.
43
+
26
44
  ## Features
27
45
 
28
46
  - `run` and `branch` DSL for defining step dependencies and conditional paths
@@ -39,4 +57,4 @@ GoodPipeline adds a coordination state machine, DAG validation, and atomic step
39
57
  - Ruby >= 3.2
40
58
  - Rails >= 7.1
41
59
  - PostgreSQL
42
- - GoodJob >= 3.10 with `preserve_job_records = true`
60
+ - GoodJob >= 4.14 with `preserve_job_records = true`
@@ -79,6 +79,8 @@ GoodPipeline.run(
79
79
 
80
80
  Both pipelines start immediately. `MergeMediaPipeline` waits for both to succeed.
81
81
 
82
+ Pipeline chaining is a first-class primitive — upstream/downstream relationships are tracked in a dedicated database table with atomic state propagation, rather than manually creating the next workflow in the last step of the current one.
83
+
82
84
  ## How `.then` works internally
83
85
 
84
86
  `.then` returns a `GoodPipeline::Chain` object which:
@@ -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
@@ -42,12 +42,8 @@ module GoodPipeline
42
42
 
43
43
  def start_pipeline(pipeline_record)
44
44
  pipeline_record.transition_to!(:running)
45
-
46
45
  root_step_ids = pipeline_record.steps.where.missing(:upstream_dependencies).pluck(:id)
47
-
48
- root_step_ids.each do |step_id|
49
- Coordinator.try_enqueue_step(step_id)
50
- end
46
+ Coordinator.bulk_enqueue_steps(root_step_ids)
51
47
  end
52
48
  end
53
49
  end
@@ -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)
@@ -44,6 +49,23 @@ module GoodPipeline
44
49
  step_was_enqueued || downstream_enqueued
45
50
  end
46
51
 
52
+ # Enqueues multiple steps in bulk using Batch.enqueue_all.
53
+ # Intended for root steps during pipeline startup where no
54
+ # concurrent enqueue risk exists and no upstream checks are needed.
55
+ def bulk_enqueue_steps(step_ids)
56
+ return if step_ids.empty?
57
+
58
+ steps = StepRecord.where(id: step_ids, coordination_status: "pending")
59
+ .where(good_job_id: nil)
60
+ .to_a
61
+
62
+ branch_steps, enqueueable_steps = steps.partition(&:branch_step?)
63
+
64
+ bulk_enqueue_user_jobs(enqueueable_steps) if enqueueable_steps.any?
65
+
66
+ branch_steps.each { |step| try_enqueue_step(step.id) }
67
+ end
68
+
47
69
  def recompute_pipeline_status(pipeline, has_active_steps: nil, has_downstream_chains: nil) # rubocop:disable Metrics/MethodLength
48
70
  return if pipeline.terminal?
49
71
 
@@ -68,12 +90,26 @@ module GoodPipeline
68
90
 
69
91
  return if rows_updated.zero?
70
92
 
71
- PipelineCallbackJob.perform_later(pipeline.id, new_status.to_s)
93
+ queue = pipeline.type.constantize.callback_queue_name
94
+ PipelineCallbackJob.set(queue: queue).perform_later(pipeline.id, new_status.to_s)
72
95
  end
73
96
  end
74
97
 
75
98
  private
76
99
 
100
+ def handle_halt_execution(step)
101
+ step.transition_coordination_status_to!(:halted)
102
+ step.pipeline.steps.pending.update_all(coordination_status: "skipped")
103
+
104
+ pipeline = load_pipeline_with_active_check(step.pipeline_id)
105
+
106
+ recompute_pipeline_status(
107
+ pipeline,
108
+ has_active_steps: pipeline["has_active_steps"],
109
+ has_downstream_chains: pipeline["has_downstream_chains"]
110
+ )
111
+ end
112
+
77
113
  def record_step_outcome(step, succeeded)
78
114
  if succeeded
79
115
  step.transition_coordination_status_to!(:succeeded)
@@ -111,6 +147,18 @@ module GoodPipeline
111
147
  scope.update_all(coordination_status: "skipped")
112
148
  end
113
149
 
150
+ def transitive_downstream_ids(step)
151
+ visited = Set.new
152
+ queue = step.downstream_steps.pluck(:id)
153
+ while (current_id = queue.shift)
154
+ next if visited.include?(current_id)
155
+
156
+ visited << current_id
157
+ queue.concat(DependencyRecord.where(depends_on_step_id: current_id).pluck(:step_id))
158
+ end
159
+ visited
160
+ end
161
+
114
162
  def unblock_downstream_steps(step)
115
163
  sql = <<~SQL
116
164
  UPDATE good_pipeline_steps
@@ -173,6 +221,7 @@ module GoodPipeline
173
221
  def permanently_unsatisfied?(upstream)
174
222
  upstream.terminal_coordination_status? &&
175
223
  !upstream.succeeded? &&
224
+ !upstream.halted? &&
176
225
  !upstream.skipped_by_branch? &&
177
226
  effective_failure_strategy(upstream) != :ignore
178
227
  end
@@ -186,6 +235,7 @@ module GoodPipeline
186
235
  def all_upstreams_satisfied?(step)
187
236
  step.upstream_steps.all? do |upstream|
188
237
  upstream.succeeded? ||
238
+ upstream.halted? ||
189
239
  upstream.skipped_by_branch? ||
190
240
  (upstream.failed? && effective_failure_strategy(upstream) == :ignore)
191
241
  end
@@ -206,6 +256,7 @@ module GoodPipeline
206
256
  def build_step_batch(step)
207
257
  batch = GoodJob::Batch.new
208
258
  batch.on_finish = "GoodPipeline::StepFinishedJob"
259
+ batch.callback_queue_name = step.pipeline.type.constantize.coordination_queue_name
209
260
  batch.properties = { step_id: step.id }
210
261
  batch
211
262
  end
@@ -216,6 +267,14 @@ module GoodPipeline
216
267
  enqueued_job.provider_job_id || enqueued_job.job_id
217
268
  end
218
269
 
270
+ def fail_step_with_error(step, error)
271
+ step.transition_coordination_status_to!(:failed)
272
+ step.update_columns(
273
+ error_class: error.class.name,
274
+ error_message: error.message
275
+ )
276
+ end
277
+
219
278
  def derive_terminal_status(pipeline)
220
279
  has_failures = pipeline.steps.where(coordination_status: "failed").exists?
221
280
 
@@ -225,24 +284,66 @@ module GoodPipeline
225
284
  :failed
226
285
  end
227
286
 
228
- def transitive_downstream_ids(step)
229
- visited = Set.new
230
- queue = step.downstream_steps.pluck(:id)
231
- while (current_id = queue.shift)
232
- next if visited.include?(current_id)
287
+ def bulk_enqueue_user_jobs(steps) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength,Metrics/CyclomaticComplexity
288
+ batch_job_pairs = []
289
+ step_metadata = {}
290
+ failed_steps = []
291
+ coordination_queue = steps.first.pipeline.type.constantize.coordination_queue_name
292
+
293
+ steps.each do |step|
294
+ job_class = begin
295
+ step.job_class.constantize
296
+ rescue NameError => error
297
+ failed_steps << [step, ConfigurationError.new(error.message)]
298
+ next
299
+ end
300
+
301
+ batch = GoodJob::Batch.new
302
+ batch.on_finish = "GoodPipeline::StepFinishedJob"
303
+ batch.callback_queue_name = coordination_queue
304
+ batch.properties = { step_id: step.id }
305
+
306
+ active_job = job_class.new(**step.params.symbolize_keys)
307
+ apply_enqueue_options(active_job, step.enqueue_options.symbolize_keys)
308
+
309
+ batch_job_pairs << [batch, [active_job]]
310
+ step_metadata[step.id] = { batch: batch, active_job: active_job }
311
+ end
233
312
 
234
- visited << current_id
235
- queue.concat(DependencyRecord.where(depends_on_step_id: current_id).pluck(:step_id))
313
+ StepRecord.transaction do
314
+ GoodJob::Batch.enqueue_all(batch_job_pairs) if batch_job_pairs.any?
315
+
316
+ now = Time.current
317
+ step_metadata.each do |step_id, metadata|
318
+ StepRecord.where(id: step_id).update_all(
319
+ coordination_status: "enqueued",
320
+ good_job_batch_id: metadata[:batch].id,
321
+ good_job_id: metadata[:active_job].provider_job_id || metadata[:active_job].job_id,
322
+ updated_at: now
323
+ )
324
+ end
325
+ end
326
+ ensure
327
+ failed_steps.each do |step, error|
328
+ fail_step_with_error(step, error)
329
+ propagate_halt(step) if step.pipeline.halt?
236
330
  end
237
- visited
238
331
  end
239
332
 
240
- def fail_step_with_error(step, error)
241
- step.transition_coordination_status_to!(:failed)
242
- step.update_columns(
243
- error_class: error.class.name,
244
- error_message: error.message
245
- )
333
+ def apply_enqueue_options(active_job, options) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
334
+ return if options.blank?
335
+
336
+ if options[:good_job_labels] && active_job.respond_to?(:good_job_labels=)
337
+ active_job.good_job_labels = Array(options[:good_job_labels])
338
+ end
339
+
340
+ if options.key?(:good_job_notify) && active_job.respond_to?(:good_job_notify=)
341
+ active_job.good_job_notify = options[:good_job_notify]
342
+ end
343
+
344
+ active_job.queue_name = options[:queue].to_s if options[:queue]
345
+ active_job.priority = options[:priority] if options[:priority]
346
+ active_job.scheduled_at = Time.current + options[:wait] if options[:wait]
246
347
  end
247
348
 
248
349
  def effective_failure_strategy(step)
@@ -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