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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +1 -1
- 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/good_pipeline/test_bulk_enqueue.rb +193 -0
- data/demo/test/good_pipeline/test_queue_configuration.rb +157 -0
- data/demo/test/integration/test_bulk_enqueue_end_to_end.rb +109 -0
- data/demo/test/integration/test_end_to_end.rb +0 -15
- data/demo/test/integration/test_halt_execution.rb +100 -0
- data/demo/test/integration/test_queue_configuration.rb +82 -0
- data/demo/test/test_helper.rb +15 -0
- data/docs/architecture.md +8 -0
- data/docs/branching.md +2 -0
- data/docs/callbacks.md +2 -0
- data/docs/defining-pipelines.md +4 -0
- data/docs/failure-strategies.md +35 -0
- data/docs/getting-started.md +12 -0
- data/docs/index.md +1 -1
- data/docs/introduction.md +19 -1
- data/docs/pipeline-chaining.md +2 -0
- data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +2 -0
- data/lib/good_pipeline/chain_coordinator.rb +1 -5
- data/lib/good_pipeline/coordinator.rb +117 -16
- data/lib/good_pipeline/engine.rb +6 -0
- data/lib/good_pipeline/haltable.rb +10 -0
- data/lib/good_pipeline/pipeline.rb +24 -1
- data/lib/good_pipeline/runner.rb +3 -3
- data/lib/good_pipeline/version.rb +1 -1
- data/lib/good_pipeline.rb +16 -0
- metadata +10 -3
|
@@ -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
|
data/demo/test/test_helper.rb
CHANGED
|
@@ -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).
|
data/docs/defining-pipelines.md
CHANGED
|
@@ -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
|
|
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 |
|
data/docs/getting-started.md
CHANGED
|
@@ -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
|
|
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 >=
|
|
60
|
+
- GoodJob >= 4.14 with `preserve_job_records = true`
|
data/docs/pipeline-chaining.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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)
|
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]
|